Skip to content

smarter path completions in REPL shell mode #51544

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 24 additions & 23 deletions base/shell.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,13 @@

const shell_special = "#{}()[]<>|&*?~;"

# strips the end but respects the space when the string ends with "\\ "
function rstrip_shell(s::AbstractString)
c_old = nothing
for (i, c) in Iterators.reverse(pairs(s))
i::Int; c::AbstractChar
((c == '\\') && c_old == ' ') && return SubString(s, 1, i+1)
isspace(c) || return SubString(s, 1, i)
c_old = c
end
SubString(s, 1, 0)
end

function shell_parse(str::AbstractString, interpolate::Bool=true;
special::AbstractString="", filename="none")
s = SubString(str, firstindex(str))
s = rstrip_shell(lstrip(s))
s = lstrip(s)

# N.B.: This is used by REPLCompletions
last_parse = 0:-1
last_parse = 1:0
isempty(s) && return interpolate ? (Expr(:tuple,:()),last_parse) : ([],last_parse)

in_single_quotes = false
Expand All @@ -34,19 +22,27 @@ function shell_parse(str::AbstractString, interpolate::Bool=true;
st = Iterators.Stateful(pairs(s))

function push_nonempty!(list, x)
if !isa(x,AbstractString) || !isempty(x)
push!(list, x)
if !isempty(list)
y = last(list)
if isa(y, AbstractString) && isempty(y)
list[end] = x
return nothing
elseif isa(x, AbstractString) && isempty(x)
return nothing
end
end
push!(list, x)
return nothing
end
function consume_upto!(list, s, i, j)
push_nonempty!(list, s[i:prevind(s, j)::Int])
something(peek(st), lastindex(s)::Int+1 => '\0').first::Int
end
function append_2to1!(list, innerlist)
if isempty(innerlist); push!(innerlist, ""); end
push!(list, copy(innerlist))
empty!(innerlist)
if !isempty(innerlist)
push!(list, copy(innerlist))
empty!(innerlist)
end
end

C = eltype(str)
Expand All @@ -57,12 +53,12 @@ function shell_parse(str::AbstractString, interpolate::Bool=true;
i = consume_upto!(arg, s, i, j)
append_2to1!(args, arg)
while !isempty(st)
# We've made sure above that we don't end in whitespace,
# so updating `i` here is ok
(i, c) = peek(st)::P
isspace(c) || break
popfirst!(st)
i += 1
end
last_parse = (i:i-1) .+ s.offset
elseif interpolate && !in_single_quotes && c == '$'
i = consume_upto!(arg, s, i, j)
isempty(st) && error("\$ right before end of command")
Expand All @@ -77,7 +73,9 @@ function shell_parse(str::AbstractString, interpolate::Bool=true;
# use parseatom instead of parse to respect filename (#28188)
ex, j = Meta.parseatom(s, stpos, filename=filename)
end
last_parse = (stpos:prevind(s, j)) .+ s.offset
if j > lastindex(s) && last(s) != ')'
last_parse = (stpos+(c=='('):lastindex(s)) .+ s.offset
end
push_nonempty!(arg, ex)
s = SubString(s, j)
Iterators.reset!(st, pairs(s))
Expand Down Expand Up @@ -121,8 +119,11 @@ function shell_parse(str::AbstractString, interpolate::Bool=true;
if in_single_quotes; error("unterminated single quote"); end
if in_double_quotes; error("unterminated double quote"); end

push_nonempty!(arg, s[i:end])
if i <= lastindex(s)
push_nonempty!(arg, s[i:end])
end
append_2to1!(args, arg)
last_parse = first(last_parse):lastindex(parent(s))

interpolate || return args, last_parse

Expand Down
4 changes: 2 additions & 2 deletions stdlib/REPL/src/REPL.jl
Original file line number Diff line number Diff line change
Expand Up @@ -562,11 +562,11 @@ function complete_line(c::REPLCompletionProvider, s::PromptState, mod::Module)
return unique!(map(completion_text, ret)), partial[range], should_complete
end

function complete_line(c::ShellCompletionProvider, s::PromptState)
function complete_line(c::ShellCompletionProvider, s::PromptState, mod::Module=nothing)
# First parse everything up to the current position
partial = beforecursor(s.input_buffer)
full = LineEdit.input_string(s)
ret, range, should_complete = shell_completions(full, lastindex(partial))
ret, range, should_complete = shell_completions(full, lastindex(partial), mod)
return unique!(map(completion_text, ret)), partial[range], should_complete
end

Expand Down
94 changes: 76 additions & 18 deletions stdlib/REPL/src/REPLCompletions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,21 @@ function completions(string::String, pos::Int, context_module::Module=Main, shif
name, pos, dotpos, startpos, comp_keywords)
end

function shell_completions(string, pos)
function common_tail(s1, s2)
i = ncodeunits(s1) + 1
itr = Iterators.Stateful(Iterators.reverse(pairs(s1)))
for ((_i, c1), c2) in zip(itr, Iterators.reverse(s2))
if c1 == '\\'
isempty(itr) && break
_i, c1 = popfirst!(itr)
end
c1 == c2 || break
i = _i
end
return SubString(s1, i)
end

function shell_completions(string, pos, mod)
# First parse everything up to the current position
scs = string[1:pos]
local args, last_parse
Expand All @@ -1246,27 +1260,71 @@ function shell_completions(string, pos)
ex = args.args[end]::Expr
# Now look at the last thing we parsed
isempty(ex.args) && return Completion[], 0:-1, false
arg = ex.args[end]
if all(s -> isa(s, AbstractString), ex.args)
arg = arg::AbstractString
# Treat this as a path

# As Base.shell_parse throws away trailing spaces (unless they are escaped),
# we need to special case here.
# If the last char was a space, but shell_parse ignored it search on "".
ignore_last_word = arg != " " && scs[end] == ' '
prefix = ignore_last_word ? "" : join(ex.args)

# Also try looking into the env path if the user wants to complete the first argument
use_envpath = !ignore_last_word && length(args.args) < 2

return complete_path(prefix, pos, use_envpath=use_envpath, shell_escape=true)
elseif isexpr(arg, :incomplete) || isexpr(arg, :error)
last_arg = ex.args[end]
if isexpr(last_arg, :incomplete) || isexpr(last_arg, :error)
partial = scs[last_parse]
ret, range = completions(partial, lastindex(partial))
ret, range = completions(partial, lastindex(partial), mod)
range = range .+ (first(last_parse) - 1)
return ret, range, true
end

# Treat this as a path

# As Base.shell_parse throws away trailing spaces (unless they are escaped),
# we need to special case here.
# If the last char was a space, but shell_parse ignored it search on "".
ignore_last_word = isempty(last_parse)
# Also try looking into the env path if the user wants to complete the first argument
is_first_arg = !ignore_last_word && length(args.args) < 2

# Try to evaluate interpolation
evaled_args = ""
if !ignore_last_word
for arg in ex.args
if mod !== nothing
t = repl_eval_ex(arg, mod; limit_aggressive_inference=true)
t isa Const || @goto ret
(; val) = t
# For the first arg non-default Cmd flags are allowed
if is_first_arg && val isa Cmd
val = val.exec
end
expanded = try
Base.arg_gen(val)::Vector{String}
catch
@goto ret
end
# Interpolation may return multiple args, always choose the last one
isempty(expanded) && @goto ret
evaled_args *= expanded[end]
else
arg isa AbstractString || @goto ret
evaled_args *= String(arg)::String
end
end
end

ret, range, should_complete = complete_path(evaled_args, pos; use_envpath=is_first_arg, shell_escape=true)

# Only replace literal user input - not interpolations - with the available completion
if !ignore_last_word
# only replace literal text in cmd literals through completions, don't delete interpolated expressions via <tab>
potentially_replaceable = common_tail(scs, evaled_args)
if potentially_replaceable !== SubString(scs, last_parse)
# last cmd arg is not just literal text
filename = basename(evaled_args)
if endswith(potentially_replaceable, filename)
range = nextind(string, last(last_parse))-ncodeunits(filename):last(last_parse)
else
range = last_parse
should_complete = false
end
end
end

return ret, range, should_complete

@label ret
return Completion[], 0:-1, false
end

Expand Down
40 changes: 39 additions & 1 deletion stdlib/REPL/test/replcompletions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ function map_completion_text(completions)
end

test_complete(s) = map_completion_text(@inferred(completions(s, lastindex(s))))
test_scomplete(s) = map_completion_text(@inferred(shell_completions(s, lastindex(s))))
test_scomplete(s, m=@__MODULE__) = map_completion_text(@inferred(shell_completions(s, lastindex(s), m)))
test_bslashcomplete(s) = map_completion_text(@inferred(bslash_completions(s, lastindex(s)))[2])
test_complete_context(s, m=@__MODULE__) = map_completion_text(@inferred(completions(s,lastindex(s), m)))
test_complete_foo(s) = test_complete_context(s, Main.CompletionFoo)
Expand Down Expand Up @@ -2089,3 +2089,41 @@ let t = REPLCompletions.repl_eval_ex(:(`a b`), @__MODULE__; limit_aggressive_inf
@test t isa Core.Const
@test t.val == `a b`
end

using Base.Filesystem: path_separator

mktempdir() do tmp
mod = Module()
mod.tmp = tmp

touch(joinpath(tmp, "foo"))

@testset "shell completions with interpolation" begin
c, r, replace = test_scomplete(raw"echo $(", mod)
@test "tmp" in c
@test isempty(r)
@test replace

c, r, replace = test_scomplete(raw"echo $(tm", mod)
@test c == Any["tmp"]
@test r === 8:9
@test replace

c, r, replace = test_scomplete(raw"echo $(tmp)", mod)
@test c == Any[splitpath(tmp)[end] * Base.escape_raw_string(path_separator)]
@test r === 6:11
@test !replace

s = raw"echo $(tmp)" * path_separator
c, r, replace = test_scomplete(s, mod)
@test c == Any["foo"]
@test r === lastindex(s) .+ (1:0)
@test replace

s = raw"echo $(tmp)" * path_separator * "fo"
c, r, replace = test_scomplete(s, mod)
@test c == Any["foo"]
@test r === lastindex(s) .+ (-1:0)
@test replace
end
end
7 changes: 6 additions & 1 deletion test/spawn.jl
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,12 @@ let p = run(`$sleepcmd 100`, wait=false)
end

# Second argument of shell_parse
let s = " \$abc "
let
s = " \$abc "
@test s[Base.shell_parse(s)[2]] == ""
@test first(Base.shell_parse(s)[2]) == nextind(s, lastindex(s))

s = " \$abc"
@test s[Base.shell_parse(s)[2]] == "abc"
end

Expand Down