Skip to content

Commit

Permalink
Add REPL completion for keyword arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
Liozou committed Jan 4, 2022
1 parent 62e6d4d commit fbc57f7
Show file tree
Hide file tree
Showing 2 changed files with 200 additions and 2 deletions.
91 changes: 91 additions & 0 deletions stdlib/REPL/src/REPLCompletions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ struct DictCompletion <: Completion
key::String
end

struct KeywordArgumentCompletion <: Completion
kwarg::String
end

# interface definition
function Base.getproperty(c::Completion, name::Symbol)
if name === :keyword
Expand All @@ -80,6 +84,8 @@ function Base.getproperty(c::Completion, name::Symbol)
return getfield(c, :text)::String
elseif name === :key
return getfield(c, :key)::String
elseif name === :kwarg
return getfield(c, :kwarg)::String
end
return getfield(c, name)
end
Expand All @@ -94,6 +100,7 @@ _completion_text(c::MethodCompletion) = sprint(io -> show(io, isnothing(c.orig_m
_completion_text(c::BslashCompletion) = c.bslash
_completion_text(c::ShellCompletion) = c.text
_completion_text(c::DictCompletion) = c.key
_completion_text(c::KeywordArgumentCompletion) = c.kwarg*'='

completion_text(c) = _completion_text(c)::String

Expand Down Expand Up @@ -559,10 +566,12 @@ function complete_methods_args(funargs::Vector{Any}, ex_org::Expr, context_modul
if isexpr(ex, :parameters)
for x in ex.args
n, v = isexpr(x, :kw) ? (x.args...,) : (x, x)
n isa Symbol || continue # happens if the current arg is splat
push!(kwargs_ex, n => get_type(get_type(v, context_module)..., default_any))
end
elseif isexpr(ex, :kw)
n, v = (ex.args...,)
n isa Symbol || continue # happens if the current arg is splat
push!(kwargs_ex, n => get_type(get_type(v, context_module)..., default_any))
else
push!(args_ex, get_type(get_type(ex, context_module)..., default_any))
Expand Down Expand Up @@ -712,6 +721,83 @@ end
return matches
end

# provide completion for keyword arguments in function calls
function complete_keyword_argument(partial, last_idx, context_module)
fail = Completion[], 0:-1

# Quickly abandon if the situation does not look like the completion of a kwarg
idx_last_punct = something(findprev(x -> ispunct(x) && x != '_' && x != '!', partial, last_idx), 0)::Int
idx_last_punct == 0 && return fail
last_punct = partial[idx_last_punct]
last_punct == ',' || last_punct == ';' || last_punct == '(' || return fail
before_last_word_start = something(findprev(in(non_identifier_chars), partial, last_idx), 0)
before_last_word_start == 0 && return fail
all(isspace, partial[nextind(partial, idx_last_punct):before_last_word_start]) || return fail
frange, method_name_end = find_start_brace(partial[1:idx_last_punct])
method_name_end frange || return fail

# At this point, we are guaranteed to be in a set of parentheses, possibly a function
# call, and the last word (currently being completed) has no internal dot (i.e. of the
# form "foo.bar") and is directly preceded by `last_punct` (one of ',', ';' or '(').
# Now, check that we are indeed in a function call
frange = first(frange):(last_punct==';' ? prevind(partial, idx_last_punct) : idx_last_punct)
s = replace(partial[frange], r"\!+([^=\(]+)" => s"\1") # strip preceding ! operator
ex = Meta.parse(s * ')', raise=false, depwarn=false)
isa(ex, Expr) || return fail
ex.head === :call || (ex.head === :. && ex.args[2] isa Expr && (ex.args[2]::Expr).head === :tuple) || return fail

# inlined `complete_methods` function since we need the `kwargs_ex` variable
func, found = get_value(ex.args[1], context_module)
!(found::Bool) && return fail
args_ex, kwargs_ex = complete_methods_args(ex.args[2:end], ex, context_module, true, true)
used_kwargs = Set{Symbol}(first(_kw) for _kw in kwargs_ex)

# Only try to complete as a kwarg if the context makes it clear that the current
# argument could be a kwarg (i.e. right after ';' or if there is another kwarg)
isempty(used_kwargs) && last_punct != ';' &&
all(x -> !(x isa Expr) || (x.head !== :kw && x.head !== :parameters), ex.args[2:end]) &&
return fail

methods = Completion[]
complete_methods!(methods, func, args_ex, kwargs_ex)

# Finally, for each method corresponding to the function call, provide completions
# suggestions for each keyword that starts like the last word and that is not already
# used previously in the expression. The corresponding suggestion is "kwname="
# If the keyword corresponds to an existing name, also include "kwname" as a suggestion
# since the syntax `foo(; bar)` is equivalent to `foo(; bar=bar)`
wordrange = nextind(partial, before_last_word_start):last_idx
last_word = partial[wordrange] # the word to complete
kwargs = Set{String}()
for m in methods
m::MethodCompletion
possible_kwargs = Base.kwarg_decl(m.orig_method isa Method ? m.orig_method : m.method)
slurp = false
current_kwarg_candidates = String[]
for _kw in possible_kwargs
kw = String(_kw)
if endswith(kw, "...")
slurp = true
elseif startswith(kw, last_word) && _kw used_kwargs
push!(current_kwarg_candidates, kw)
end
end
# Only suggest kwargs from a method if that method can accept all the kwargs
# already present in the call, or if it slurps keyword arguments
if slurp || used_kwargs possible_kwargs
union!(kwargs, current_kwarg_candidates)
end
end

suggestions = Completion[]
for kwarg in kwargs
push!(suggestions, KeywordArgumentCompletion(kwarg))
end
append!(suggestions, complete_symbol(last_word, (mod,x)->true, context_module))

return sort!(suggestions, by=completion_text), wordrange
end

function project_deps_get_completion_candidates(pkgstarts::String, project_file::String)
loading_candidates = String[]
d = Base.parsed_toml(project_file)
Expand Down Expand Up @@ -812,6 +898,11 @@ function completions(string::String, pos::Int, context_module::Module=Main, shif
return Completion[], 0:-1, false
end

# Check whether we can complete a keyword argument in a function call
kwarg_completion, wordrange = complete_keyword_argument(partial, pos, context_module)
isempty(wordrange) || return kwarg_completion, wordrange, !isempty(kwarg_completion)


dotpos = something(findprev(isequal('.'), string, pos), 0)
startpos = nextind(string, something(findprev(in(non_identifier_chars), string, pos), 0))
# strip preceding ! operator
Expand Down
111 changes: 109 additions & 2 deletions stdlib/REPL/test/replcompletions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ let ex = quote
test9(x::Char, i::Int) = pass
kwtest(; x=1, y=2, w...) = pass
kwtest2(a; x=1, y=2, w...) = pass
kwtest3(a::Number; length, len2, foobar, kwargs...) = pass
kwtest3(a::Real; another!kwarg, len2) = pass
kwtest3(a::Integer; namedarg, foobar, slurp...) = pass
kwtest4(a::AbstractString; _a1b, x23) = pass
kwtest4(a::String; _a1b, xαβγ) = pass
kwtest4(a::SubString; x23, _something) = pass

const named = (; len2=3)

array = [1, 1]
varfloat = 0.1
Expand Down Expand Up @@ -568,14 +576,14 @@ let s = "CompletionFoo.?('c'"
@test any(str->occursin("test9(x::Char, i::Int", str), c)
end

let s = "CompletionFoo.?(false, \"a\", 3, "
let s = "CompletionFoo.?(false, \"a\", 3, Val(7), "
c, r, res = test_complete(s)
@test !res
@test length(c) == 1
@test occursin("test(args...)", c[1])
end

let s = "CompletionFoo.?(false, \"a\", 3, "
let s = "CompletionFoo.?(false, \"a\", 3, Val(7), "
c, r, res = test_complete_noshift(s)
@test !res
@test isempty(c)
Expand Down Expand Up @@ -633,6 +641,13 @@ let s = "CompletionFoo.test6()[1](CompletionFoo.Test_y(rand())).y"
@test c[1] == "yy"
end

let s = "CompletionFoo.named."
c, r = test_complete(s)
@test length(c) == 1
@test r == (lastindex(s) + 1):lastindex(s)
@test c[1] == "len2"
end

# Test completion in multi-line comments
let s = "#=\n\\alpha"
c, r, res = test_complete(s)
Expand Down Expand Up @@ -1104,6 +1119,98 @@ test_dict_completion("test_repl_comp_customdict")
@test "tϵsτcmδ`" in c
end

@testset "Keyword-argument completion" begin
c, r = test_complete("CompletionFoo.kwtest3(a;foob")
@test c == ["foobar="]
c, r = test_complete("CompletionFoo.kwtest3(a; le")
@test "length" c # provide this kind of completion in case the user wants to splat a variable
@test "length=" c
@test "len2=" c
@test "len2" c
c, r = test_complete("CompletionFoo.kwtest3.(a;\nlength")
@test "length" c
@test "length=" c
c, r = test_complete("CompletionFoo.kwtest3(a, length=4, l")
@test "length" c
@test "length=" c # since it was already used, do not suggest it again
@test "len2=" c
c, r = test_complete("CompletionFoo.kwtest3(a; kwargs..., fo")
@test "foreach" c # provide this kind of completion in case the user wants to splat a variable
@test "foobar=" c
c, r = test_complete("CompletionFoo.kwtest3(a; another!kwarg=0, le")
@test "length" c
@test "length=" c # the first method could be called and `anotherkwarg` slurped
@test "len2=" c
c, r = test_complete("CompletionFoo.kwtest3(a; another!")
@test c == ["another!kwarg="]
c, r = test_complete("CompletionFoo.kwtest3(a; another!kwarg=0, foob")
@test c == ["foobar="] # the first method could be called and `anotherkwarg` slurped
c, r = test_complete("CompletionFoo.kwtest3(a; namedarg=0, foob")
@test c == ["foobar="]

# Check for confusion with CompletionFoo.named
c, r = test_complete_foo("kwtest3(blabla; unknown=4, namedar")
@test c == ["namedarg="]
c, r = test_complete_foo("kwtest3(blabla; named")
@test "named" c
@test "namedarg=" c
@test "len2" c
c, r = test_complete_foo("kwtest3(blabla; named.")
@test c == ["len2"]
c, r = test_complete_foo("kwtest3(blabla; named..., another!")
@test c == ["another!kwarg="]
c, r = test_complete_foo("kwtest3(blabla; named..., len")
@test "length" c
@test "length=" c
@test "len2=" c
c, r = test_complete_foo("kwtest3(1+3im; named")
@test "named" c
@test "namedarg=" c
@test "len2" c
c, r = test_complete_foo("kwtest3(1+3im; named.")
@test c == ["len2"]

c, r = test_complete("CompletionFoo.kwtest4(a; x23=0, _")
@test "_a1b=" c
@test "_something=" c
c, r = test_complete("CompletionFoo.kwtest4(a; xαβγ=1, _")
@test "_a1b=" c
@test "_something=" c # no such keyword for the method with keyword `xαβγ`
c, r = test_complete("CompletionFoo.kwtest4(a; x23=0, x")
@test "x23=" c
@test "xαβγ=" c
c, r = test_complete("CompletionFoo.kwtest4(a; _a1b=1, x")
@test "x23=" c
@test "xαβγ=" c


# return true if no completion suggests a keyword argument
function hasnokwsuggestions(str)
c, _ = test_complete(str)
return !any(x -> endswith(x, r"[a-z]="), c)
end
@test hasnokwsuggestions("Completio")
@test hasnokwsuggestions("CompletionFoo.kwt")
@test hasnokwsuggestions("CompletionFoo.kwtest3(")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a")
@test hasnokwsuggestions("CompletionFoo.kwtest3(le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a;")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; len2=")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; len2=le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; len2=3 ")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; [le")
@test hasnokwsuggestions("CompletionFoo.kwtest3([length; le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; (le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; foo(le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; (; le")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; length, ")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; kwargs..., ")
@test hasnokwsuggestions("CompletionFoo.kwtest3(a; unknown=4, another!kw") # only methods 1 and 3 could slurp `unknown`
@test hasnokwsuggestions("CompletionFoo.kwtest3(1+3im; nameda")

@test_broken hasnokwsuggestions("CompletionFoo.kwtest3(12//7; foob")
end

# Test completion in context

# No CompletionFoo.CompletionFoo
Expand Down

0 comments on commit fbc57f7

Please sign in to comment.