From 046e280fb49f5597adc9748e8229e086cfac17b7 Mon Sep 17 00:00:00 2001 From: Em Chu Date: Thu, 29 May 2025 08:54:19 -0700 Subject: [PATCH 01/15] Implement "signature help" --- src/JETLS.jl | 5 + src/LSP/LSP.jl | 1 + src/LSP/capabilities.jl | 2 + src/LSP/language-features/signature-help.jl | 251 ++++++++++++++++++++ src/completions.jl | 47 ---- src/signature-help.jl | 238 +++++++++++++++++++ src/utils.jl | 75 +++++- 7 files changed, 571 insertions(+), 48 deletions(-) create mode 100644 src/LSP/language-features/signature-help.jl create mode 100644 src/signature-help.jl diff --git a/src/JETLS.jl b/src/JETLS.jl index 445e2ee3c..6497b770f 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -98,6 +98,7 @@ end include("utils.jl") include("completions.jl") +include("signature-help.jl") struct IncludeCallback <: Function file_cache::Dict{URI,FileInfo} @@ -186,6 +187,8 @@ function _handle_message(state::ServerState, msg) return handle_CompletionRequest(state, msg) elseif msg isa CompletionResolveRequest return handle_CompletionResolveRequest(state, msg) + elseif msg isa SignatureHelpRequest + return handle_SignatureHelpRequest(state, msg) elseif JETLS_DEV_MODE @warn "Unhandled message" msg end @@ -245,6 +248,8 @@ function initialize_result() triggerCharacters = ["@"], completionItem = (; labelDetailsSupport = true)), + signatureHelpProvider = SignatureHelpOptions(; + triggerCharacters = ["(", ","]), ), serverInfo = (; name = "JETLS", diff --git a/src/LSP/LSP.jl b/src/LSP/LSP.jl index 301f1608f..c254ecb7c 100644 --- a/src/LSP/LSP.jl +++ b/src/LSP/LSP.jl @@ -16,6 +16,7 @@ include("lifecycle-messages/exit.jl") include("document-synchronization.jl") include("language-features/diagnostics.jl") include("language-features/completions.jl") +include("language-features/signature-help.jl") include("workspace-features/workspace-folders.jl") include("workspace-features/files.jl") include("capabilities.jl") diff --git a/src/LSP/capabilities.jl b/src/LSP/capabilities.jl index 1252fec8e..5a5004fa8 100644 --- a/src/LSP/capabilities.jl +++ b/src/LSP/capabilities.jl @@ -28,6 +28,8 @@ completionProvider::Union{CompletionOptions, Nothing} = nothing + signatureHelpProvider::Union{SignatureHelpOptions, Nothing} = nothing + "Workspace specific server capabilities" workspace::Union{Nothing, @interface begin """ diff --git a/src/LSP/language-features/signature-help.jl b/src/LSP/language-features/signature-help.jl new file mode 100644 index 000000000..6b2ce896a --- /dev/null +++ b/src/LSP/language-features/signature-help.jl @@ -0,0 +1,251 @@ +@interface SignatureHelpClientCapabilities begin + """ + Whether signature help supports dynamic registration. + """ + dynamicRegistration::Union{Nothing, Bool} = nothing + + """ + The client supports the following `SignatureInformation` + specific properties. + """ + signatureInformation::Union{Nothing, @interface begin + """ + Client supports the follow content formats for the documentation + property. The order describes the preferred format of the client. + """ + documentationFormat::Union{Nothing, Vector{MarkupKind}} = nothing + + """ + Client capabilities specific to parameter information. + """ + parameterInformation::Union{Nothing, @interface begin + """ + The client supports processing label offsets instead of a + simple label string. + + # Tags + - since - 3.14.0 + """ + labelOffsetSupport::Union{Nothing, Bool} = nothing + end} = nothing + + """ + The client supports the `activeParameter` property on + `SignatureInformation` literal. + + # Tags + - since - 3.16.0 + """ + activeParameterSupport::Union{Nothing, Bool} = nothing + end} = nothing + + """ + The client supports to send additional context information for a + `textDocument/signatureHelp` request. A client that opts into + contextSupport will also support the `retriggerCharacters` on + `SignatureHelpOptions`. + + # Tags + - since - 3.15.0 + """ + contextSupport::Union{Nothing, Bool} = nothing +end + +@interface SignatureHelpOptions @extends WorkDoneProgressOptions begin + """ + The characters that trigger signature help + automatically. + """ + triggerCharacters::Union{Nothing, Vector{String}} = nothing + + """ + List of characters that re-trigger signature help. + + These trigger characters are only active when signature help is already + showing. All trigger characters are also counted as re-trigger + characters. + + # Tags + - since - 3.15.0 + """ + retriggerCharacters::Union{Nothing, Vector{String}} = nothing +end + +@interface SignatureHelpRegistrationOptions @extends TextDocumentRegistrationOptions, SignatureHelpOptions begin +end + +""" +How a signature help was triggered. + +# Tags +- since - 3.15.0 +""" +@namespace SignatureHelpTriggerKind::Int begin + """ + Signature help was invoked manually by the user or by a command. + """ + Invoked = 1 + """ + Signature help was triggered by a trigger character. + """ + TriggerCharacter = 2 + """ + Signature help was triggered by the cursor moving or by the document + content changing. + """ + ContentChange = 3 +end + +""" +Represents a parameter of a callable-signature. A parameter can +have a label and a doc-comment. +""" +@interface ParameterInformation begin + + """ + The label of this parameter information. + + Either a string or an inclusive start and exclusive end offsets within + its containing signature label. (see SignatureInformation.label). The + offsets are based on a UTF-16 string representation as `Position` and + `Range` does. + + *Note*: a label of type string should be a substring of its containing + signature label. Its intended use case is to highlight the parameter + label part in the `SignatureInformation.label`. + """ + label::Union{String, Vector{UInt}} # vector should have length 2 + + """ + The human-readable doc-comment of this parameter. Will be shown + in the UI but can be omitted. + """ + documentation::Union{Nothing, String, MarkupContent} = nothing +end + +""" +Represents the signature of something callable. A signature +can have a label, like a function-name, a doc-comment, and +a set of parameters. +""" +@interface SignatureInformation begin + """ + The label of this signature. Will be shown in + the UI. + """ + label::String + + """ + The human-readable doc-comment of this signature. Will be shown + in the UI but can be omitted. + """ + documentation::Union{Nothing, String, MarkupContent} = nothing + + """ + The parameters of this signature. + """ + parameters::Union{Nothing, Vector{ParameterInformation}} = nothing + + """ + The index of the active parameter. + + If provided, this is used in place of `SignatureHelp.activeParameter`. + + # Tags + - since - 3.16.0 + """ + activeParameter::Union{Nothing, UInt} = nothing +end + +""" +Signature help represents the signature of something +callable. There can be multiple signature but only one +active and only one active parameter. +""" +@interface SignatureHelp begin + """ + One or more signatures. If no signatures are available the signature help + request should return `null`. + """ + signatures::Vector{SignatureInformation} + + """ + The active signature. If omitted or the value lies outside the + range of `signatures` the value defaults to zero or is ignore if + the `SignatureHelp` as no signatures. + + Whenever possible implementors should make an active decision about + the active signature and shouldn't rely on a default value. + + In future version of the protocol this property might become + mandatory to better express this. + """ + activeSignature::Union{Nothing, UInt} = nothing + + """ + The active parameter of the active signature. If omitted or the value + lies outside the range of `signatures[activeSignature].parameters` + defaults to 0 if the active signature has parameters. If + the active signature has no parameters it is ignored. + In future version of the protocol this property might become + mandatory to better express the active parameter if the + active signature does have any. + """ + activeParameter::Union{Nothing, UInt} = nothing +end + +""" +Additional information about the context in which a signature help request +was triggered. + +# Tags +- since - 3.15.0 +""" +@interface SignatureHelpContext begin + """ + Action that caused signature help to be triggered. + """ + triggerKind::SignatureHelpTriggerKind.Ty + + """ + Character that caused signature help to be triggered. + + This is undefined when triggerKind !== + SignatureHelpTriggerKind.TriggerCharacter + """ + triggerCharacter::Union{Nothing, String} = nothing + + """ + `true` if signature help was already showing when it was triggered. + + Retriggers occur when the signature help is already active and can be + caused by actions such as typing a trigger character, a cursor move, or + document content changes. + """ + isRetrigger::Bool + + """ + The currently active `SignatureHelp`. + + The `activeSignatureHelp` has its `SignatureHelp.activeSignature` field + updated based on the user navigating through available signatures. + """ + activeSignatureHelp::Union{Nothing, SignatureHelp} = nothing +end + +@interface SignatureHelpParams @extends TextDocumentPositionParams, WorkDoneProgressParams begin + """ + The signature help context. This is only available if the client + specifies to send this using the client capability + `textDocument.signatureHelp.contextSupport === true` + + # Tags + - since - 3.15.0 + """ + context::Union{Nothing, SignatureHelpContext} = nothing +end + +@interface SignatureHelpRequest @extends RequestMessage begin + method::String = "textDocument/signatureHelp" + params::SignatureHelpParams +end diff --git a/src/completions.jl b/src/completions.jl index 4987b2413..be47829e7 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -32,53 +32,6 @@ end # local completions # ================= -""" -Like `Base.unique`, but over node ids, and with this comment promising that the -lowest-index copy of each node is kept. -""" -function deduplicate_syntaxlist(sl::JL.SyntaxList) - sl2 = JL.SyntaxList(sl.graph) - seen = Set{JL.NodeId}() - for st in sl - if !(st._id in seen) - push!(sl2, st._id) - push!(seen, st._id) - end - end - return sl2 -end - -""" - byte_ancestors(st::JL.SyntaxTree, rng::UnitRange{Int}) - byte_ancestors(st::JL.SyntaxTree, byte::Int) - -Get a list of `SyntaxTree`s containing certain bytes. -Output should be topologically sorted, children first. - -If we know that parent ranges contain all child ranges, and that siblings don't -have overlapping ranges (this is not true after lowering, but appear to be true -after parsing), each tree in the result will be a child of the next. -""" -function byte_ancestors(st::JL.SyntaxTree, rng::UnitRange{Int}) - sl = JL.SyntaxList(st._graph, [st._id]) - stack = [st] - while !isempty(stack) - st = pop!(stack) - if JS.numchildren(st) === 0 - continue - end - for ci in JS.children(st) - if rng ⊆ JS.byte_range(ci) - push!(sl, ci) - end - push!(stack, ci) - end - end - # delete later duplicates when sorted parent->child - return reverse!(deduplicate_syntaxlist(sl)) -end -byte_ancestors(st::JL.SyntaxTree, byte::Int) = byte_ancestors(st, byte:byte) - """ Find any largest lowerable tree containing the cursor and the cursor's position within it. For local completions; something like least_unlowerable would be diff --git a/src/signature-help.jl b/src/signature-help.jl new file mode 100644 index 000000000..dd8bee22b --- /dev/null +++ b/src/signature-help.jl @@ -0,0 +1,238 @@ +using .JS +using .JL + +function make_paraminfo(p::JL.SyntaxTree) + # A parameter's `label` is either a string the client searches for, or + # an inclusive-exclusive range within in the signature. + srcloc = (x::JL.SyntaxTree) -> let r = JS.byte_range(x); + [UInt(r.start-1), UInt(r.stop)] + end + + # defaults: whole parameter expression + label = srcloc(p) + documentation = string(JS.sourcetext(p)) + + if JS.is_leaf(p) + documentation = nothing + elseif kind(p) === K"=" + @assert JS.numchildren(p) === 2 + label = kwname(p, #=msig=#true) # TODO kwname should return syntaxtree + elseif kind(p) === K"::" + if JS.numchildren(p) === 1 + documentation = "(unused) " * documentation + else + @assert JS.numchildren(p) === 2 + label = srcloc(p[1]) + end + elseif kind(p) === K"..." + label = make_paraminfo(p[1]).label + end + # do clients tolerate string labels better? + # if !isa(label, String) + # label = string(p.source.file[label[1]:label[2]]) + # end + return ParameterInformation(; label, documentation) +end + +""" +Return (args, first_kwarg_i), one SyntaxTree per argument to call. Ignore function +name and K"error" (e.g. missing closing paren) +""" +function flatten_call_args(call::JL.SyntaxTree) + if kind(call) === K"where" + return flatten_call_args(call[1]) + end + @assert kind(call) === K"call" || kind(call) === K"dotcall" + usable = (arg::JL.SyntaxTree) -> (kind(arg) != K"parameters" && kind(arg) != K"error") + args_lhs = filter(usable, JS.children(call)[2:end]) + args_rhs = kind(call[end]) === K"parameters" ? + filter(usable, JS.children(call[end])) : JL.SyntaxTree[] + return vcat(args_lhs, args_rhs), length(args_lhs) + 1 +end + +# a, (= a 1), (= (:: a T) 1) +function kwname(a::JL.SyntaxTree, msig=false) + if kind(a) === K"Identifier" + return a.name_val + elseif kind(a) === K"=" && kind(a[1]) === K"Identifier" + return a[1].name_val + elseif msig && kind(a) === K"=" && kind(a[1]) === K"::" && kind(a[1][1]) === K"Identifier" + return a[1][1].name_val + end + JETLS_DEV_MODE && (@info "Unknown kwarg form" a) + return nothing +end + +# False negatives are fine here; false positives would hide signatures. +""" +Map kwname to position in `args`. args[kw_i] and later are after the semicolon. +If `msig`, then K"=" before the semicolon should be interpreted as optional +positional args instead of kwargs. +""" +function find_kws(args::Vector{JL.SyntaxTree}, kw_i::Int, msig=false) + out = Dict{String, Int}() + for i in (msig ? (kw_i:lastindex(args)) : eachindex(args)) + (kind(args[i]) != K"=") && i < kw_i && continue + n = kwname(args[i]) + if !isnothing(n) + out[n] = i + end + end + return out +end + +function make_siginfo(m::Method, active_arg::Union{String, Int, Nothing}) + # methodshow prints "f(x::T) [unparseable stuff]" + # parse the first part and put the remainder in documentation + mstr = sprint(show, m) + mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] + label, documentation = let b = JS.last_byte(mnode) + mstr[1:b], string(strip(mstr[b+1:end])) + end + + # We could show the full docs, but there isn't(?) a way to separate by + # method (or resolve one at a time), and the user may have seen this already + # in the completions UI. + # documentation = MarkupContent(; + # kind = MarkupKind.Markdown, + # value = string(Base.Docs.doc(Base.Docs.Binding(m.var"module", m.name)))) + + f_params, kw_i = flatten_call_args(mnode) + activeParameter = if isnothing(active_arg) + nothing + elseif active_arg isa String + kwmap = find_kws(f_params, kw_i, #=msig=#true) + get(kwmap, active_arg, nothing) - 1 # TODO post-semicolon vararg + elseif active_arg isa Int && active_arg >= kw_i + # @assert + nothing # TODO pre-semicolon vararg + elseif active_arg isa Int + active_arg - 1 + end + @info "active param calculated:" active_arg activeParameter + + parameters = map(make_paraminfo, f_params) + return SignatureInformation(; label, documentation, parameters, activeParameter) +end + +""" +Return false if we can definitely rule out `f(args...|` from being a call to `m` +""" +function compatible_call(m::Method, args::Vector{JL.SyntaxTree}, used_kws, pos_map) + # TODO: (later) This should use type information from args (which we already + # have from m's params). For now, just parse the method signature like we + # do in make_siginfo. + + mstr = sprint(show, m) + mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] + params, kw_i = flatten_call_args(mnode) + has_kw_splat = kw_i <= length(params) && + length(params) >= 1 && + kind(params[end]) === K"..." + kwp_map = find_kws(params, kw_i, #=msig=#true) + + (length(pos_map) >= kw_i) && return false + !has_kw_splat && !(keys(used_kws) ⊆ keys(kwp_map)) && return false + return true +end + +""" +Resolve a name's value given a root module and an expression like `M1.M2.M3.f`, +which parses to `(. (. (. M1 M2) M3) f)`. If we hit something undefined, return +nothing. This doesn't support some cases, e.g. `(print("hi"); Base).print` +""" +function resolve_property(mod::Module, rhs::JL.SyntaxTree) + if JS.is_leaf(rhs) + # Would otherwise throw an unhelpful error. Is this true of all leaf nodes? + @assert JL.hasattr(rhs, :name_val) + s = Symbol(rhs.name_val) + !isdefined(mod, s) && return nothing + return getproperty(mod, s) + elseif kind(rhs) === K"." + @assert JS.numchildren(rhs) === 2 + lhs = resolve_property(mod, rhs[1]) + return resolve_property(lhs, rhs[2]) + elseif JETLS_DEV_MODE + @info "resolve_property couldn't handle form:" mod rhs + end +end + +function get_siginfos(s::ServerState, msg::SignatureHelpRequest) + uri = URI(msg.params.textDocument.uri) + fi = get_fileinfo(s, uri) + mod = find_file_module!(s, uri, msg.params.position) + b = xy_to_offset(fi, msg.params.position) + out = SignatureInformation[] + + call = let st0 = JS.build_tree(JL.SyntaxTree, fi.parsed_stream; ignore_errors=true) + bas = byte_ancestors(st0, b) + i = findfirst(st -> JS.kind(st) === K"call", bas) + i === nothing && return out + bas[i] + end + # TODO: dotcall support + JS.numchildren(call) === 0 && return out + + # TODO: We could be calling a local variable. If it shadows a method, our + # ignoring it is misleading. We need to either know about local variables + # in this scope (maybe by caching completion info) or duplicate some work. + fn = resolve_property(mod, call[1]) + !isa(fn, Function) && return out + + args, kw_i = flatten_call_args(call) + @info "flatten_call_args" args kw_i + + pos_map = Int[] # which positional arg => position in `args` + for i in eachindex(args[1:kw_i-1]) + if kind(args[i]) === K"..." + break # don't know beyond here + elseif kind(args[i]) === K"=" + continue + else + push!(pos_map, i) + end + end + + # we don't keep commas---do we want the green node here? + active_arg = let i = findfirst(a -> JS.byte_range(a).stop + 1 >= b, args) + if isnothing(i) || kind(args[i]) === K"..." + nothing + elseif kind(args[i]) === K"=" || i >= kw_i + kwname(args[i]) + elseif i in pos_map + findfirst(x -> x === i, pos_map) + else + JETLS_DEV_MODE && (@info "No active arg" i args[i] call) + nothing + end + end + + used_kws = find_kws(args, kw_i) + + for m in methods(fn) + if compatible_call(m, args, used_kws, pos_map) + # TODO: don't suggest signature we are currently editing + push!(out, make_siginfo(m, active_arg)) + end + end + return out +end + +""" +textDocument/signatureHelp is requested when one of the negotiated trigger +characters is typed. Eglot (emacs) requests it more frequently. +""" +function handle_SignatureHelpRequest(s::ServerState, msg::SignatureHelpRequest) + signatures = get_siginfos(s, msg) + activeSignature = 0 + activeParameter = nothing + return s.send( + ResponseMessage(; + id = msg.id, + result = isempty(signatures) ? + null + : SignatureHelp(; + signatures, + activeSignature, + activeParameter))) +end diff --git a/src/utils.jl b/src/utils.jl index e29f7baca..613a9e819 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -45,6 +45,34 @@ Fetch cached FileInfo given an LSclient-provided structure with a URI get_fileinfo(s::ServerState, uri::URI) = haskey(s.file_cache, uri) ? s.file_cache[uri] : nothing get_fileinfo(s::ServerState, t::TextDocumentIdentifier) = get_fileinfo(s, URI(t.uri)) + +function find_file_module!(state::ServerState, uri::URI, pos::Position) + mod = find_file_module(state, uri, pos) + state.completion_module[] = mod + return mod +end +function find_file_module(state::ServerState, uri::URI, pos::Position) + haskey(state.contexts, uri) || return Main + contexts = state.contexts[uri] + context = first(contexts) + for ctx in contexts + # prioritize `PackageSourceAnalysisEntry` if exists + if isa(context.entry, PackageSourceAnalysisEntry) + context = ctx + break + end + end + haskey(context.analyzed_file_infos, uri) || return Main + analyzed_file_info = context.analyzed_file_infos[uri] + curline = Int(pos.line) + 1 + curmod = Main + for (range, mod) in analyzed_file_info.module_range_infos + curline in range || continue + curmod = mod + end + return curmod +end + # JuliaLowering uses byte offsets; LSP uses lineno and UTF-* character offset. # These functions do the conversion. @@ -99,12 +127,57 @@ end offset_to_xy(fi::FileInfo, byte::Int) = offset_to_xy(fi.parsed_stream, byte) """ +Like `Base.unique`, but over node ids, and with this comment promising that the +lowest-index copy of each node is kept. +""" +function deduplicate_syntaxlist(sl::JL.SyntaxList) + sl2 = JL.SyntaxList(sl.graph) + seen = Set{JL.NodeId}() + for st in sl + if !(st._id in seen) + push!(sl2, st._id) + push!(seen, st._id) + end + end + return sl2 +end + +""" + byte_ancestors(st::JL.SyntaxTree, rng::UnitRange{Int}) + byte_ancestors(st::JL.SyntaxTree, byte::Int) + +Get a SyntaxList of `SyntaxTree`s containing certain bytes. + byte_ancestors(sn::JS.SyntaxNode, rng::UnitRange{Int}) byte_ancestors(sn::JS.SyntaxNode, byte::Int) Get a list of `SyntaxNode`s containing certain bytes. -Output should be topologically sorted, children first. + +Output should be topologically sorted, children first. If we know that parent +ranges contain all child ranges, and that siblings don't have overlapping ranges +(this is not true after lowering, but appears to be true after parsing), each +tree in the result will be a child of the next. """ +function byte_ancestors(st::JL.SyntaxTree, rng::UnitRange{Int}) + sl = JL.SyntaxList(st._graph, [st._id]) + stack = [st] + while !isempty(stack) + st = pop!(stack) + if JS.numchildren(st) === 0 + continue + end + for ci in JS.children(st) + if rng ⊆ JS.byte_range(ci) + push!(sl, ci) + end + push!(stack, ci) + end + end + # delete later duplicates when sorted parent->child + return reverse!(deduplicate_syntaxlist(sl)) +end +byte_ancestors(st::JL.SyntaxTree, byte::Int) = byte_ancestors(st, byte:byte) + function byte_ancestors(sn::JS.SyntaxNode, rng::UnitRange{Int}) out = JS.SyntaxNode[] stack = JS.SyntaxNode[sn] From a8f92d26dab3e36ba22e5ab3ac7b0a77ac1e6334 Mon Sep 17 00:00:00 2001 From: Em Chu Date: Thu, 29 May 2025 13:34:31 -0700 Subject: [PATCH 02/15] misc fixes --- src/JETLS.jl | 2 +- src/completions.jl | 27 ---- src/signature-help.jl | 364 +++++++++++++++++++++++++----------------- src/utils.jl | 7 +- 4 files changed, 224 insertions(+), 176 deletions(-) diff --git a/src/JETLS.jl b/src/JETLS.jl index 6497b770f..2d3d79847 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -249,7 +249,7 @@ function initialize_result() completionItem = (; labelDetailsSupport = true)), signatureHelpProvider = SignatureHelpOptions(; - triggerCharacters = ["(", ","]), + triggerCharacters = ["(", ",", ";"]), ), serverInfo = (; name = "JETLS", diff --git a/src/completions.jl b/src/completions.jl index be47829e7..35b4ebe91 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -254,33 +254,6 @@ end # global completions # ================== -function find_file_module!(state::ServerState, uri::URI, pos::Position) - mod = find_file_module(state, uri, pos) - state.completion_module[] = mod - return mod -end -function find_file_module(state::ServerState, uri::URI, pos::Position) - haskey(state.contexts, uri) || return Main - contexts = state.contexts[uri] - context = first(contexts) - for ctx in contexts - # prioritize `PackageSourceAnalysisEntry` if exists - if isa(context.entry, PackageSourceAnalysisEntry) - context = ctx - break - end - end - safi = successfully_analyzed_file_info(context, uri) - isnothing(safi) && return Main - curline = Int(pos.line) + 1 - curmod = Main - for (range, mod) in safi.module_range_infos - curline in range || continue - curmod = mod - end - return curmod -end - function global_completions!(items::Dict{String, CompletionItem}, state::ServerState, uri::URI, params::CompletionParams) pos = params.position is_macro_invoke = let context = params.context diff --git a/src/signature-help.jl b/src/signature-help.jl index dd8bee22b..935f42147 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -1,174 +1,258 @@ using .JS using .JL -function make_paraminfo(p::JL.SyntaxTree) - # A parameter's `label` is either a string the client searches for, or - # an inclusive-exclusive range within in the signature. - srcloc = (x::JL.SyntaxTree) -> let r = JS.byte_range(x); - [UInt(r.start-1), UInt(r.stop)] - end - - # defaults: whole parameter expression - label = srcloc(p) - documentation = string(JS.sourcetext(p)) +# utils +# ===== - if JS.is_leaf(p) - documentation = nothing - elseif kind(p) === K"=" - @assert JS.numchildren(p) === 2 - label = kwname(p, #=msig=#true) # TODO kwname should return syntaxtree - elseif kind(p) === K"::" - if JS.numchildren(p) === 1 - documentation = "(unused) " * documentation - else - @assert JS.numchildren(p) === 2 - label = srcloc(p[1]) - end - elseif kind(p) === K"..." - label = make_paraminfo(p[1]).label +""" +Resolve a name's value given a root module and an expression like `M1.M2.M3.f`, +which parses to `(. (. (. M1 M2) M3) f)`. If we hit something undefined, return +nothing. This doesn't support some cases, e.g. `(print("hi"); Base).print` +""" +function resolve_property(mod::Module, st0::JL.SyntaxTree) + if JS.is_leaf(st0) + # Would otherwise throw an unhelpful error. Is this true of all leaf nodes? + @assert JL.hasattr(st0, :name_val) + s = Symbol(st0.name_val) + !isdefined(mod, s) && return nothing + return getproperty(mod, s) + elseif kind(st0) === K"." + @assert JS.numchildren(st0) === 2 + lhs = resolve_property(mod, st0[1]) + return resolve_property(lhs, st0[2]) end - # do clients tolerate string labels better? - # if !isa(label, String) - # label = string(p.source.file[label[1]:label[2]]) - # end - return ParameterInformation(; label, documentation) + JETLS_DEV_MODE && @info "resolve_property couldn't handle form:" mod st0 + return nothing end """ Return (args, first_kwarg_i), one SyntaxTree per argument to call. Ignore function name and K"error" (e.g. missing closing paren) """ -function flatten_call_args(call::JL.SyntaxTree) +function flatten_args(call::JL.SyntaxTree) if kind(call) === K"where" - return flatten_call_args(call[1]) + return flatten_args(call[1]) end @assert kind(call) === K"call" || kind(call) === K"dotcall" - usable = (arg::JL.SyntaxTree) -> (kind(arg) != K"parameters" && kind(arg) != K"error") - args_lhs = filter(usable, JS.children(call)[2:end]) - args_rhs = kind(call[end]) === K"parameters" ? - filter(usable, JS.children(call[end])) : JL.SyntaxTree[] - return vcat(args_lhs, args_rhs), length(args_lhs) + 1 + usable = (arg::JL.SyntaxTree) -> kind(arg) != K"error" + orig = filter(usable, JS.children(call)[2:end]) + + args = JL.SyntaxTree[] + kw_i = 1 + for i in eachindex(orig) + iskw = kind(orig[i]) === K"parameters" + if !iskw + push!(args, orig[i]) + kw_i += 1 + elseif i === lastindex(orig) && iskw + for p in filter(usable, JS.children(orig[i])) + push!(args, p) + end + end + end + return args, kw_i end -# a, (= a 1), (= (:: a T) 1) -function kwname(a::JL.SyntaxTree, msig=false) +""" +Get K"Identifier" tree from a kwarg tree (child of K"call" or K"parameters"). +`sig`: treat this as a signature rather than a call + a => a + (= a 1) => a + (= (:: a T) 1) => a # only when sig=true +""" +function kwname(a::JL.SyntaxTree; sig=false) if kind(a) === K"Identifier" - return a.name_val + return a elseif kind(a) === K"=" && kind(a[1]) === K"Identifier" - return a[1].name_val - elseif msig && kind(a) === K"=" && kind(a[1]) === K"::" && kind(a[1][1]) === K"Identifier" - return a[1][1].name_val + return a[1] + elseif sig && kind(a) === K"=" && kind(a[1]) === K"::" && kind(a[1][1]) === K"Identifier" + return a[1][1] + elseif kind(a) === K"..." + return nothing end - JETLS_DEV_MODE && (@info "Unknown kwarg form" a) + JETLS_DEV_MODE && @info "Unknown kwarg form" a return nothing end -# False negatives are fine here; false positives would hide signatures. """ -Map kwname to position in `args`. args[kw_i] and later are after the semicolon. -If `msig`, then K"=" before the semicolon should be interpreted as optional +Best-effort mapping of kwname to position in `args`. args[kw_i] and later are +after the semicolon. False negatives are fine here; false positives would hide +signatures. + +If `sig`, then K"=" trees before the semicolon should be interpreted as optional positional args instead of kwargs. """ -function find_kws(args::Vector{JL.SyntaxTree}, kw_i::Int, msig=false) +function find_kws(args::Vector{JL.SyntaxTree}, kw_i::Int; sig=false) out = Dict{String, Int}() - for i in (msig ? (kw_i:lastindex(args)) : eachindex(args)) + for i in (sig ? (kw_i:lastindex(args)) : eachindex(args)) (kind(args[i]) != K"=") && i < kw_i && continue - n = kwname(args[i]) + n = kwname(args[i]; sig) if !isnothing(n) - out[n] = i + out[n.name_val] = i end end return out end -function make_siginfo(m::Method, active_arg::Union{String, Int, Nothing}) - # methodshow prints "f(x::T) [unparseable stuff]" - # parse the first part and put the remainder in documentation - mstr = sprint(show, m) - mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] - label, documentation = let b = JS.last_byte(mnode) - mstr[1:b], string(strip(mstr[b+1:end])) - end +""" +Information from one call's arguments for filtering signatures. +- args: Every valid child of the K"call" and its K"parameters" if present +- kw_i: One plus the number of args not in K"parameters" (semicolon) +- pos_map: Map from position in `args` to (min, max) possible positional arg + e.g. f(a, k=1, b..., c) --> a => (1, 1), c => (2, nothing) +- kw_map: kwname => position in `args` - # We could show the full docs, but there isn't(?) a way to separate by - # method (or resolve one at a time), and the user may have seen this already - # in the completions UI. - # documentation = MarkupContent(; - # kind = MarkupKind.Markdown, - # value = string(Base.Docs.doc(Base.Docs.Binding(m.var"module", m.name)))) +TODO: types +""" +struct CallArgs + args::Vector{JL.SyntaxTree} + kw_i::Int + pos_map::Dict{Int, Tuple{Int, Union{Int, Nothing}}} + pos_args_lb::Int + pos_args_ub::Union{Int, Nothing} + kw_map::Dict{String, Int} +end - f_params, kw_i = flatten_call_args(mnode) - activeParameter = if isnothing(active_arg) - nothing - elseif active_arg isa String - kwmap = find_kws(f_params, kw_i, #=msig=#true) - get(kwmap, active_arg, nothing) - 1 # TODO post-semicolon vararg - elseif active_arg isa Int && active_arg >= kw_i - # @assert - nothing # TODO pre-semicolon vararg - elseif active_arg isa Int - active_arg - 1 +function CallArgs(st0::JL.SyntaxTree) + args, kw_i = flatten_args(st0) + pos_map = Dict{Int, Tuple{Int, Union{Int, Nothing}}}() + lb = 0; ub = 0 + for i in eachindex(args[1:kw_i-1]) + if kind(args[i]) === K"..." + ub = nothing + elseif kind(args[i]) != K"=" + lb += 1 + !isnothing(ub) && (ub += 1) + pos_map[i] = (lb, ub) + end end - @info "active param calculated:" active_arg activeParameter - - parameters = map(make_paraminfo, f_params) - return SignatureInformation(; label, documentation, parameters, activeParameter) + kw_map = find_kws(args, kw_i; sig=false) + CallArgs(args, kw_i, pos_map, lb, ub, kw_map) end """ Return false if we can definitely rule out `f(args...|` from being a call to `m` """ -function compatible_call(m::Method, args::Vector{JL.SyntaxTree}, used_kws, pos_map) +function compatible_call(m::Method, ca::CallArgs) # TODO: (later) This should use type information from args (which we already # have from m's params). For now, just parse the method signature like we # do in make_siginfo. mstr = sprint(show, m) mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] - params, kw_i = flatten_call_args(mnode) - has_kw_splat = kw_i <= length(params) && - length(params) >= 1 && - kind(params[end]) === K"..." - kwp_map = find_kws(params, kw_i, #=msig=#true) - - (length(pos_map) >= kw_i) && return false - !has_kw_splat && !(keys(used_kws) ⊆ keys(kwp_map)) && return false + + params, kwp_i = flatten_args(mnode) + has_var_params = kwp_i > 1 && kind(params[kwp_i - 1]) === K"..." + has_var_kwp = kwp_i <= length(params) && kind(params[end]) === K"..." + + kwp_map = find_kws(params, kwp_i; sig=true) + + !has_var_params && (ca.pos_args_lb >= kwp_i) && return false + !has_var_kwp && !(keys(ca.kw_map) ⊆ keys(kwp_map)) && return false return true end -""" -Resolve a name's value given a root module and an expression like `M1.M2.M3.f`, -which parses to `(. (. (. M1 M2) M3) f)`. If we hit something undefined, return -nothing. This doesn't support some cases, e.g. `(print("hi"); Base).print` -""" -function resolve_property(mod::Module, rhs::JL.SyntaxTree) - if JS.is_leaf(rhs) - # Would otherwise throw an unhelpful error. Is this true of all leaf nodes? - @assert JL.hasattr(rhs, :name_val) - s = Symbol(rhs.name_val) - !isdefined(mod, s) && return nothing - return getproperty(mod, s) - elseif kind(rhs) === K"." - @assert JS.numchildren(rhs) === 2 - lhs = resolve_property(mod, rhs[1]) - return resolve_property(lhs, rhs[2]) - elseif JETLS_DEV_MODE - @info "resolve_property couldn't handle form:" mod rhs +# LSP objects and handler +# ======================= + +function make_paraminfo(p::JL.SyntaxTree) + # A parameter's `label` is either a string the client searches for, or + # an inclusive-exclusive range within in the signature. + srcloc = (x::JL.SyntaxTree) -> let r = JS.byte_range(x); + [UInt(r.start-1), UInt(r.stop)] end + + # defaults: whole parameter expression + label = srcloc(p) + documentation = string(JS.sourcetext(p)) + + if JS.is_leaf(p) + documentation = nothing + elseif kind(p) === K"=" + @assert JS.numchildren(p) === 2 + label = kwname(p; sig=true).name_val + elseif kind(p) === K"::" + if JS.numchildren(p) === 1 + documentation = "(unused) " * documentation + else + @assert JS.numchildren(p) === 2 + label = srcloc(p[1]) + end + elseif kind(p) === K"..." + label = make_paraminfo(p[1]).label + end + # do clients tolerate string labels better? + # if !isa(label, String) + # label = string(p.source.file[label[1]:label[2]]) + # end + return ParameterInformation(; label, documentation) end -function get_siginfos(s::ServerState, msg::SignatureHelpRequest) - uri = URI(msg.params.textDocument.uri) - fi = get_fileinfo(s, uri) - mod = find_file_module!(s, uri, msg.params.position) - b = xy_to_offset(fi, msg.params.position) - out = SignatureInformation[] +# active_arg is either an argument index, or :next (available pos. arg), or :none +function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) + # methodshow prints "f(x::T) [unparseable stuff]" + # parse the first part and put the remainder in documentation + mstr = sprint(show, m) + mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] + label, documentation = let lb = JS.last_byte(mnode) + mstr[1:lb], string(strip(mstr[lb+1:end])) + end + + # We could show the full docs, but there isn't(?) a way to separate by + # method (or resolve items lazily like completions), so we would be sending + # many copies. The user may have seen this already in the completions UI, + # too. + # documentation = MarkupContent(; + # kind = MarkupKind.Markdown, + # value = string(Base.Docs.doc(Base.Docs.Binding(m.var"module", m.name)))) + + params, kwp_i = flatten_args(mnode) + maybe_var_params = kwp_i > 1 && kind(params[kwp_i - 1]) === K"..." ? + kwp_i - 1 : nothing + maybe_var_kwp = kwp_i <= length(params) && kind(params[end]) === K"..." ? + lastindex(params) : nothing + kwp_map = find_kws(params, kwp_i; sig=true) - call = let st0 = JS.build_tree(JL.SyntaxTree, fi.parsed_stream; ignore_errors=true) - bas = byte_ancestors(st0, b) + # Map active arg to active param, or nothing + activeParameter = let i = active_arg + if i === :none + nothing + elseif i === :next # next pos arg if able + kwp_i > ca.kw_i ? ca.kw_i : nothing + elseif kind(ca.args[i]) === K"..." + # vararg if we can, nothing if not + i >= kwp_i ? maybe_var_kwp : nothing + elseif i in keys(ca.pos_map) + lb, ub = get(ca.pos_map, i, (1, nothing)) + if !isnothing(maybe_var_params) && lb >= maybe_var_params + maybe_var_params + else + lb === ub ? lb : nothing + end + elseif kind(ca.args[i]) === K"=" || i >= ca.kw_i + n = kwname(ca.args[i]).name_val # we don't have a backwards mapping + out = get(kwp_map, n, nothing) + isnothing(out) ? maybe_var_kwp : out + else + JETLS_DEV_MODE && @info "No active arg" i ca.args[i] call + nothing + end + end + + !isnothing(activeParameter) && (activeParameter -= 1) # shift to 0-based + parameters = map(make_paraminfo, params) + return SignatureInformation(; label, documentation, parameters, activeParameter) +end + +function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) + out = SignatureInformation[] + call, after_semicolon = let st0 = JS.build_tree(JL.SyntaxTree, ps; ignore_errors=true) + # tolerate one-past-last byte. TODO: go back to closest non-whitespace + bas = byte_ancestors(st0, b-1) i = findfirst(st -> JS.kind(st) === K"call", bas) i === nothing && return out - bas[i] + after_semicolon = i > 1 && kind(bas[i-1]) === K"parameters" && b > JS.first_byte(bas[i-1]) + bas[i], after_semicolon end # TODO: dotcall support JS.numchildren(call) === 0 && return out @@ -179,40 +263,28 @@ function get_siginfos(s::ServerState, msg::SignatureHelpRequest) fn = resolve_property(mod, call[1]) !isa(fn, Function) && return out - args, kw_i = flatten_call_args(call) - @info "flatten_call_args" args kw_i + ca = CallArgs(call) - pos_map = Int[] # which positional arg => position in `args` - for i in eachindex(args[1:kw_i-1]) - if kind(args[i]) === K"..." - break # don't know beyond here - elseif kind(args[i]) === K"=" - continue + # Influence parameter highlighting by selecting the active argument (which + # may be mapped to a parameter in make_siginfo). If cursor is after all + # pos. args and not after semicolon, ask for the next param, which may not + # exist. Otherwise, highlight the param for the arg we're in. + # + # We don't keep commas---do we want the green node here? + active_arg = let no_args = ca.kw_i === 1, + past_pos_args = !no_args && b > JS.last_byte(ca.args[ca.kw_i - 1]) + 1 + if past_pos_args && !after_semicolon + :next else - push!(pos_map, i) + arg_i = findfirst(a -> JS.first_byte(a) <= b <= JS.last_byte(a) + 1, ca.args) + isnothing(arg_i) ? :none : arg_i end end - - # we don't keep commas---do we want the green node here? - active_arg = let i = findfirst(a -> JS.byte_range(a).stop + 1 >= b, args) - if isnothing(i) || kind(args[i]) === K"..." - nothing - elseif kind(args[i]) === K"=" || i >= kw_i - kwname(args[i]) - elseif i in pos_map - findfirst(x -> x === i, pos_map) - else - JETLS_DEV_MODE && (@info "No active arg" i args[i] call) - nothing - end - end - - used_kws = find_kws(args, kw_i) - + for m in methods(fn) - if compatible_call(m, args, used_kws, pos_map) + if compatible_call(m, ca) # TODO: don't suggest signature we are currently editing - push!(out, make_siginfo(m, active_arg)) + push!(out, make_siginfo(m, ca, active_arg)) end end return out @@ -223,7 +295,11 @@ textDocument/signatureHelp is requested when one of the negotiated trigger characters is typed. Eglot (emacs) requests it more frequently. """ function handle_SignatureHelpRequest(s::ServerState, msg::SignatureHelpRequest) - signatures = get_siginfos(s, msg) + uri = URI(msg.params.textDocument.uri) + fi = get_fileinfo(s, uri) + mod = find_file_module!(s, uri, msg.params.position) + b = xy_to_offset(fi, msg.params.position) + signatures = cursor_siginfos(mod, fi.parsed_stream, b) activeSignature = 0 activeParameter = nothing return s.send( diff --git a/src/utils.jl b/src/utils.jl index 613a9e819..f264f1405 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -45,7 +45,6 @@ Fetch cached FileInfo given an LSclient-provided structure with a URI get_fileinfo(s::ServerState, uri::URI) = haskey(s.file_cache, uri) ? s.file_cache[uri] : nothing get_fileinfo(s::ServerState, t::TextDocumentIdentifier) = get_fileinfo(s, URI(t.uri)) - function find_file_module!(state::ServerState, uri::URI, pos::Position) mod = find_file_module(state, uri, pos) state.completion_module[] = mod @@ -62,11 +61,11 @@ function find_file_module(state::ServerState, uri::URI, pos::Position) break end end - haskey(context.analyzed_file_infos, uri) || return Main - analyzed_file_info = context.analyzed_file_infos[uri] + safi = successfully_analyzed_file_info(context, uri) + isnothing(safi) && return Main curline = Int(pos.line) + 1 curmod = Main - for (range, mod) in analyzed_file_info.module_range_infos + for (range, mod) in safi.module_range_infos curline in range || continue curmod = mod end From 359ed79d5597b3b24a364e159aeae94a5a65afd4 Mon Sep 17 00:00:00 2001 From: Em Chu Date: Fri, 30 May 2025 10:39:01 -0700 Subject: [PATCH 03/15] Detect when we're in a method definition and suppress signature help --- src/signature-help.jl | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index 935f42147..c8c06365f 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -247,10 +247,21 @@ end function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) out = SignatureInformation[] call, after_semicolon = let st0 = JS.build_tree(JL.SyntaxTree, ps; ignore_errors=true) - # tolerate one-past-last byte. TODO: go back to closest non-whitespace + # tolerate one-past-last byte. TODO: go back to closest non-whitespace? bas = byte_ancestors(st0, b-1) i = findfirst(st -> JS.kind(st) === K"call", bas) i === nothing && return out + + # If parents of our call are like (function (where (where ... (call |) ...))), + # we're actually in a declaration, and shouldn't show signature help. + # Are there other cases this misses? + @info st0 + j = i + 1 + while j + 1 <= lastindex(bas) && kind(bas[j+1]) === K"where" + j += 1 + end + j <= lastindex(bas) && kind(bas[j]) === K"function" && return out + after_semicolon = i > 1 && kind(bas[i-1]) === K"parameters" && b > JS.first_byte(bas[i-1]) bas[i], after_semicolon end From a5b631c668b30aca0c2e1c0e5ccadf33c0d7f64d Mon Sep 17 00:00:00 2001 From: Em Chu Date: Fri, 30 May 2025 10:40:39 -0700 Subject: [PATCH 04/15] Small fixes --- src/signature-help.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index c8c06365f..798132211 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -183,7 +183,7 @@ function make_paraminfo(p::JL.SyntaxTree) end # do clients tolerate string labels better? # if !isa(label, String) - # label = string(p.source.file[label[1]:label[2]]) + # label = string(p.source.file[label[1]+1:label[2]]) # end return ParameterInformation(; label, documentation) end @@ -221,7 +221,7 @@ function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) kwp_i > ca.kw_i ? ca.kw_i : nothing elseif kind(ca.args[i]) === K"..." # vararg if we can, nothing if not - i >= kwp_i ? maybe_var_kwp : nothing + i >= ca.kw_i ? maybe_var_kwp : nothing elseif i in keys(ca.pos_map) lb, ub = get(ca.pos_map, i, (1, nothing)) if !isnothing(maybe_var_params) && lb >= maybe_var_params @@ -294,7 +294,6 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) for m in methods(fn) if compatible_call(m, ca) - # TODO: don't suggest signature we are currently editing push!(out, make_siginfo(m, ca, active_arg)) end end From 00d478f512a83611939882b6cffc5bdba9d0d45b Mon Sep 17 00:00:00 2001 From: Em Chu Date: Fri, 30 May 2025 14:34:05 -0700 Subject: [PATCH 05/15] Some tests. Param highlighting TODO --- src/signature-help.jl | 1 - test/runtests.jl | 1 + test/test_signature_help.jl | 143 ++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 test/test_signature_help.jl diff --git a/src/signature-help.jl b/src/signature-help.jl index 798132211..de4a8ebeb 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -255,7 +255,6 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) # If parents of our call are like (function (where (where ... (call |) ...))), # we're actually in a declaration, and shouldn't show signature help. # Are there other cases this misses? - @info st0 j = i + 1 while j + 1 <= lastindex(bas) && kind(bas[j+1]) === K"where" j += 1 diff --git a/test/runtests.jl b/test/runtests.jl index 85e8692e8..4ad27fc4b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,5 +9,6 @@ end @testset "JETLS" begin @testset "utils" include("test_utils.jl") @testset "completions" include("test_completions.jl") + @testset "signature help" include("test_signature_help.jl") @testset "full lifecycle" include("test_full_lifecycle.jl") end diff --git a/test/test_signature_help.jl b/test/test_signature_help.jl new file mode 100644 index 000000000..043be6a8d --- /dev/null +++ b/test/test_signature_help.jl @@ -0,0 +1,143 @@ +module test_signature_help + +using Test +using JETLS +using JETLS: JL, JS +using JETLS: cursor_siginfos + +# siginfos(mod, code, cursor="|") -> siginfos +# nsigs(mod, code, cursor="|") + +function siginfos(mod::Module, code::String, cursor::String="|") + b = findfirst(cursor, code).start + ps = JS.ParseStream(replace(code, cursor=>"", count=1)); JS.parse!(ps) + return cursor_siginfos(mod, ps, b) +end + +n_si(args...) = let si = siginfos(args...) + length(si) +end + +module M_sanity +i_exist(a,b,c) = 0 +end +@testset "sanity" begin + + @test 1 === n_si(M_sanity, "i_exist(|)") + @test 1 === n_si(M_sanity, "i_exist(1,2,3|)") + @test 1 === n_si(M_sanity, "i_exist(|1,2,3)") + @test 0 === n_si(M_sanity, "i_do_not_exist(|)") + @test 0 === n_si(M_sanity, "|") + @test 0 === n_si(M_sanity, "(|)") + @test 0 === n_si(M_sanity, "()|") +end + + +@testset "don't show help in method definitions" begin + snippets = [ + "function f(|); end", + "function f(|) where T; end", + "function f(|) where T where T; end", + "f(|) = 1", + "f(|) where T = 1", + ] + for s in snippets + @test 0 === n_si(Main, s) + end +end + + +module M_filterp +f4() = 0 +f4(a) = 0 +f4(a,b) = 0 +f4(a,b,c) = 0 +f4(a,b,c,d) = 0 + +f1v() = 0 +f1v(a) = 0 +f1v(a, args...) = 0 +end +@testset "filter by number of positional args" begin + + @test 5 === n_si(M_filterp, "f4(|)") + @test 4 === n_si(M_filterp, "f4(1|)") + @test 4 === n_si(M_filterp, "f4(1,|)") + @test 4 === n_si(M_filterp, "f4(1, |)") + @test 3 === n_si(M_filterp, "f4(1,2|)") + @test 2 === n_si(M_filterp, "f4(1,2,3|)") + @test 1 === n_si(M_filterp, "f4(1,2,3,4,|)") + + @test 1 === n_si(M_filterp, "f4(|1,2,3,4,)") + @test 1 === n_si(M_filterp, "f4(1,2,3,4; |)") + + # vararg should be assumed empty for filtering purposes + @test 1 === n_si(M_filterp, "f4(1,2,3,4,x...|)") + @test 1 === n_si(M_filterp, "f4(x...,1,2,3,4,|)") + + @test 3 === n_si(M_filterp, "f1v(|)") + @test 2 === n_si(M_filterp, "f1v(1,|)") + @test 1 === n_si(M_filterp, "f1v(1,2|)") + @test 1 === n_si(M_filterp, "f1v(1,2,3|)") + @test 1 === n_si(M_filterp, "f1v(1,2,3,foo...|)") +end + +module M_filterk +f(;kw1, kw2=2, kw3::Int=3) = 0 +f(x; kw2, kw3, kw4, kw5, kw6) = 0 +end +@testset "filter by names of kwargs" begin + + @test 2 === n_si(M_filterk, "f(|)") + + # pre-semicolon + @test 0 === n_si(M_filterk, "f(1, kw1|)") # positional until we type "=" + @test 1 === n_si(M_filterk, "f(kw1=1|)") + + # post-semicolon + @test 1 === n_si(M_filterk, "f(;kw1|)") + @test 1 === n_si(M_filterk, "f(;kw1=|)") + @test 1 === n_si(M_filterk, "f(;kw1=1|)") + + # mix + @test 2 === n_si(M_filterk, "f(kw2=2,kw3=3;|)") + @test 2 === n_si(M_filterk, "f(kw2=2; kw3=3|)") + @test 2 === n_si(M_filterk, "f(kw2=2; kw3|)") + @test 1 === n_si(M_filterk, "f(kw2=2; kw6|)") +end + +@testset "param highlighting" begin + # ap(mod::Module, code::String, cursor::String="|") + # TODO +end + +module M_nested +inner(args...) = 0 +outer(args...) = 0 +end +@testset "nested" begin + + active_si(code) = only(siginfos(M_nested, code)).label + + @test startswith(active_si("outer(0,1,inner(|))"), "inner") + @test startswith(active_si("outer(0,1,inner()|)"), "inner") + @test startswith(active_si("outer(0,1,|inner())"), "outer") # either is fine really + @test startswith(active_si("outer(0,1|,inner())"), "outer") + @test startswith(active_si("outer(0,1,inner(),|)"), "outer") + @test startswith(active_si("function outer(); inner(|); end"), "inner") +end + +# This depends somewhat on what JuliaSyntax does using `ignore_errors=true`, +# which I don't think is specified, but it would be good to know if these common +# cases break. +module M_invalid +f1(a; k=1) = 0 +end +@testset "tolerate invalid syntax" begin + @test 1 === n_si(M_invalid, "f1(|") + @test 1 === n_si(M_invalid, "f1(,,,,,,,,,|)") + @test 1 === n_si(M_invalid, "f1(a b c|)") + @test 1 === n_si(M_invalid, "f1(k=|)") + +end +end From a97153b856637856827c04d996ef5413655b988f Mon Sep 17 00:00:00 2001 From: Em Chu Date: Mon, 2 Jun 2025 09:15:50 -0700 Subject: [PATCH 06/15] Param highlighting tests and small tweaks --- src/JETLS.jl | 3 ++- src/signature-help.jl | 13 +++++++------ test/test_signature_help.jl | 38 ++++++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/JETLS.jl b/src/JETLS.jl index 2d3d79847..0e0aa082d 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -249,7 +249,8 @@ function initialize_result() completionItem = (; labelDetailsSupport = true)), signatureHelpProvider = SignatureHelpOptions(; - triggerCharacters = ["(", ",", ";"]), + triggerCharacters = ["(", ",", ";"], + retriggerCharacters = ["."]), ), serverInfo = (; name = "JETLS", diff --git a/src/signature-help.jl b/src/signature-help.jl index de4a8ebeb..d36fe5769 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -99,7 +99,7 @@ Information from one call's arguments for filtering signatures. - args: Every valid child of the K"call" and its K"parameters" if present - kw_i: One plus the number of args not in K"parameters" (semicolon) - pos_map: Map from position in `args` to (min, max) possible positional arg - e.g. f(a, k=1, b..., c) --> a => (1, 1), c => (2, nothing) + e.g. f(a, k=1, b..., c) --> a => (1, 1), b => (2, nothing), c => (2, nothing) - kw_map: kwname => position in `args` TODO: types @@ -120,6 +120,7 @@ function CallArgs(st0::JL.SyntaxTree) for i in eachindex(args[1:kw_i-1]) if kind(args[i]) === K"..." ub = nothing + pos_map[i] = (lb, ub) elseif kind(args[i]) != K"=" lb += 1 !isnothing(ub) && (ub += 1) @@ -219,9 +220,6 @@ function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) nothing elseif i === :next # next pos arg if able kwp_i > ca.kw_i ? ca.kw_i : nothing - elseif kind(ca.args[i]) === K"..." - # vararg if we can, nothing if not - i >= ca.kw_i ? maybe_var_kwp : nothing elseif i in keys(ca.pos_map) lb, ub = get(ca.pos_map, i, (1, nothing)) if !isnothing(maybe_var_params) && lb >= maybe_var_params @@ -229,6 +227,9 @@ function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) else lb === ub ? lb : nothing end + elseif kind(ca.args[i]) === K"..." + # splat after semicolon + maybe_var_kwp elseif kind(ca.args[i]) === K"=" || i >= ca.kw_i n = kwname(ca.args[i]).name_val # we don't have a backwards mapping out = get(kwp_map, n, nothing) @@ -282,7 +283,7 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) # # We don't keep commas---do we want the green node here? active_arg = let no_args = ca.kw_i === 1, - past_pos_args = !no_args && b > JS.last_byte(ca.args[ca.kw_i - 1]) + 1 + past_pos_args = no_args || b > JS.last_byte(ca.args[ca.kw_i - 1]) + 1 if past_pos_args && !after_semicolon :next else @@ -309,7 +310,7 @@ function handle_SignatureHelpRequest(s::ServerState, msg::SignatureHelpRequest) mod = find_file_module!(s, uri, msg.params.position) b = xy_to_offset(fi, msg.params.position) signatures = cursor_siginfos(mod, fi.parsed_stream, b) - activeSignature = 0 + activeSignature = nothing activeParameter = nothing return s.send( ResponseMessage(; diff --git a/test/test_signature_help.jl b/test/test_signature_help.jl index 043be6a8d..af496d1a1 100644 --- a/test/test_signature_help.jl +++ b/test/test_signature_help.jl @@ -71,7 +71,7 @@ end @test 1 === n_si(M_filterp, "f4(|1,2,3,4,)") @test 1 === n_si(M_filterp, "f4(1,2,3,4; |)") - # vararg should be assumed empty for filtering purposes + # splat should be assumed empty for filtering purposes @test 1 === n_si(M_filterp, "f4(1,2,3,4,x...|)") @test 1 === n_si(M_filterp, "f4(x...,1,2,3,4,|)") @@ -106,9 +106,41 @@ end @test 1 === n_si(M_filterk, "f(kw2=2; kw6|)") end +module M_highlight +f(a0, a1, a2, va3...; kw4=0, kw5=0, kws6...) = 0 +end @testset "param highlighting" begin - # ap(mod::Module, code::String, cursor::String="|") - # TODO + function ap(mod::Module, code::String, cursor::String="|") + si = siginfos(mod, code, cursor) + p = only(si).activeParameter + isnothing(p) ? nothing : Int(p) + end + @test 0 === ap(M_highlight, "f(|)") + @test 0 === ap(M_highlight, "f(0|)") + @test 1 === ap(M_highlight, "f(0,|)") + @test 1 === ap(M_highlight, "f(0, |)") + + # in vararg + @test 3 === ap(M_highlight, "f(0, 1, 2, 3|)") + @test 3 === ap(M_highlight, "f(0, 1, 2, 3, 3|)") + @test 3 === ap(M_highlight, "f(0, 1, 2, 3, x...|)") + # splat contains 0 or more args; use what we know + @test nothing === ap(M_highlight, "f(x...|, 0, 1, 2, 3, x...)") + @test nothing === ap(M_highlight, "f(x..., 0, 1, 2|, 3, x...)") + @test 3 === ap(M_highlight, "f(x..., 0, 1, 2, 3|, x...)") + @test 3 === ap(M_highlight, "f(x..., 0, 1, 2, 3, x...|)") + + # various kwarg + @test 4 === ap(M_highlight, "f(0, 1, 2, 3; kw4|)") + @test 4 === ap(M_highlight, "f(0, 1, 2, 3; kw4=0|)") + @test 4 === ap(M_highlight, "f(|kw4=0, 0, 1, 2, 3)") + @test 0 === ap(M_highlight, "f(kw4=0, 0|, 1, 2, 3)") + # any old kwarg can go in `kws6...` + @test 6 === ap(M_highlight, "f(0, 1, 2, 3; kwfake|)") + @test 6 === ap(M_highlight, "f(0, 1, 2, 3; kwfake=1|)") + @test 6 === ap(M_highlight, "f(kwfake=1|, 0, 1, 2, 3)") + # splat after semicolon + @test 6 === ap(M_highlight, "f(0, 1, 2, 3; kwfake...|)") end module M_nested From c1df48f85d33e913f6babe7af93fe709ceefd103 Mon Sep 17 00:00:00 2001 From: Em Chu <61633163+mlechu@users.noreply.github.com> Date: Mon, 2 Jun 2025 09:17:04 -0700 Subject: [PATCH 07/15] Update src/LSP/language-features/signature-help.jl Co-authored-by: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> --- src/LSP/language-features/signature-help.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LSP/language-features/signature-help.jl b/src/LSP/language-features/signature-help.jl index 6b2ce896a..8278424a0 100644 --- a/src/LSP/language-features/signature-help.jl +++ b/src/LSP/language-features/signature-help.jl @@ -13,7 +13,7 @@ Client supports the follow content formats for the documentation property. The order describes the preferred format of the client. """ - documentationFormat::Union{Nothing, Vector{MarkupKind}} = nothing + documentationFormat::Union{Nothing, Vector{MarkupKind.Ty}} = nothing """ Client capabilities specific to parameter information. From 02fc549f4e39d478a6c6716a3721128af397fb24 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Tue, 3 Jun 2025 18:38:28 +0900 Subject: [PATCH 08/15] adjustments to the latest master --- src/signature-help.jl | 11 ++++++----- src/utils.jl | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index d36fe5769..9fd3ad862 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -291,7 +291,7 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) isnothing(arg_i) ? :none : arg_i end end - + for m in methods(fn) if compatible_call(m, ca) push!(out, make_siginfo(m, ca, active_arg)) @@ -304,15 +304,16 @@ end textDocument/signatureHelp is requested when one of the negotiated trigger characters is typed. Eglot (emacs) requests it more frequently. """ -function handle_SignatureHelpRequest(s::ServerState, msg::SignatureHelpRequest) +function handle_SignatureHelpRequest(server::Server, msg::SignatureHelpRequest) + state = server.state uri = URI(msg.params.textDocument.uri) - fi = get_fileinfo(s, uri) - mod = find_file_module!(s, uri, msg.params.position) + fi = get_fileinfo(state, uri) + mod = find_file_module!(state, uri, msg.params.position) b = xy_to_offset(fi, msg.params.position) signatures = cursor_siginfos(mod, fi.parsed_stream, b) activeSignature = nothing activeParameter = nothing - return s.send( + return send(server, ResponseMessage(; id = msg.id, result = isempty(signatures) ? diff --git a/src/utils.jl b/src/utils.jl index 0f3f54689..5a2ac5adc 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -56,7 +56,7 @@ get_fileinfo(s::ServerState, t::TextDocumentIdentifier) = get_fileinfo(s, URI(t. function find_file_module!(state::ServerState, uri::URI, pos::Position) mod = find_file_module(state, uri, pos) - state.completion_module[] = mod + state.completion_module = mod return mod end function find_file_module(state::ServerState, uri::URI, pos::Position) From d1f3b5eebe85d06da07533a9e3d950317defa0f1 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Tue, 3 Jun 2025 20:08:24 +0900 Subject: [PATCH 09/15] fix `var"..."` nonsense from JET internals, add full tests --- src/JETLS.jl | 6 ++- src/LSP/language-features/signature-help.jl | 4 ++ src/signature-help.jl | 16 +++--- src/utils.jl | 27 ++++++---- test/setup.jl | 20 +++++++- test/test_diagnostics.jl | 9 ---- test/test_signature_help.jl | 57 ++++++++++++++++++--- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/src/JETLS.jl b/src/JETLS.jl index baa4dfb69..31bbc2b50 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -62,6 +62,7 @@ end mutable struct FullAnalysisResult staled::Bool last_analysis::Float64 + actual2virtual::JET.Actual2Virtual const uri2diagnostics::Dict{URI,Vector{Diagnostic}} const analyzed_file_infos::Dict{URI,JET.AnalyzedFileInfo} const successfully_analyzed_file_infos::Dict{URI,JET.AnalyzedFileInfo} @@ -506,7 +507,7 @@ function new_analysis_context(entry::AnalysisEntry, result) uri2diagnostics = jet_result_to_diagnostics(keys(analyzed_file_infos), result) successfully_analyzed_file_infos = copy(analyzed_file_infos) is_full_analysis_successful(result) || empty!(successfully_analyzed_file_infos) - analysis_result = FullAnalysisResult(false, time(), uri2diagnostics, analyzed_file_infos, successfully_analyzed_file_infos) + analysis_result = FullAnalysisResult(false, time(), result.res.actual2virtual, uri2diagnostics, analyzed_file_infos, successfully_analyzed_file_infos) return AnalysisContext(entry, analysis_result) end @@ -534,6 +535,9 @@ function update_analysis_context!(analysis_context::AnalysisContext, result) jet_result_to_diagnostics!(uri2diagnostics, result) analysis_context.result.staled = false analysis_context.result.last_analysis = time() + if is_full_analysis_successful(result) + analysis_context.result.actual2virtual = result.res.actual2virtual + end end # TODO This reverse map recording should respect the changes made in `include` chains diff --git a/src/LSP/language-features/signature-help.jl b/src/LSP/language-features/signature-help.jl index 8278424a0..4dce72420 100644 --- a/src/LSP/language-features/signature-help.jl +++ b/src/LSP/language-features/signature-help.jl @@ -249,3 +249,7 @@ end method::String = "textDocument/signatureHelp" params::SignatureHelpParams end + +@interface SignatureHelpResponse @extends ResponseMessage begin + result::Union{SignatureHelp, Null} +end diff --git a/src/signature-help.jl b/src/signature-help.jl index 9fd3ad862..78ad757c0 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -190,10 +190,10 @@ function make_paraminfo(p::JL.SyntaxTree) end # active_arg is either an argument index, or :next (available pos. arg), or :none -function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) +function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}, postprocessor::JET.PostProcessor=JET.PostProcessor()) # methodshow prints "f(x::T) [unparseable stuff]" # parse the first part and put the remainder in documentation - mstr = sprint(show, m) + mstr = postprocessor(sprint(show, m)) mnode = JS.parsestmt(JL.SyntaxTree, mstr; ignore_errors=true)[1] label, documentation = let lb = JS.last_byte(mnode) mstr[1:lb], string(strip(mstr[lb+1:end])) @@ -245,7 +245,7 @@ function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}) return SignatureInformation(; label, documentation, parameters, activeParameter) end -function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) +function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int, postprocessor::JET.PostProcessor=JET.PostProcessor()) out = SignatureInformation[] call, after_semicolon = let st0 = JS.build_tree(JL.SyntaxTree, ps; ignore_errors=true) # tolerate one-past-last byte. TODO: go back to closest non-whitespace? @@ -294,7 +294,7 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int) for m in methods(fn) if compatible_call(m, ca) - push!(out, make_siginfo(m, ca, active_arg)) + push!(out, make_siginfo(m, ca, active_arg, postprocessor)) end end return out @@ -308,13 +308,15 @@ function handle_SignatureHelpRequest(server::Server, msg::SignatureHelpRequest) state = server.state uri = URI(msg.params.textDocument.uri) fi = get_fileinfo(state, uri) - mod = find_file_module!(state, uri, msg.params.position) + mod = find_file_module(state, uri, msg.params.position) + context = find_context_for_uri(state, uri) + postprocessor = JET.PostProcessor(isnothing(context) ? nothing : context.result.actual2virtual) b = xy_to_offset(fi, msg.params.position) - signatures = cursor_siginfos(mod, fi.parsed_stream, b) + signatures = cursor_siginfos(mod, fi.parsed_stream, b, postprocessor) activeSignature = nothing activeParameter = nothing return send(server, - ResponseMessage(; + SignatureHelpResponse(; id = msg.id, result = isempty(signatures) ? null diff --git a/src/utils.jl b/src/utils.jl index 5a2ac5adc..2bfd64dd3 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -60,16 +60,8 @@ function find_file_module!(state::ServerState, uri::URI, pos::Position) return mod end function find_file_module(state::ServerState, uri::URI, pos::Position) - haskey(state.contexts, uri) || return Main - contexts = state.contexts[uri] - context = first(contexts) - for ctx in contexts - # prioritize `PackageSourceAnalysisEntry` if exists - if isa(context.entry, PackageSourceAnalysisEntry) - context = ctx - break - end - end + context = find_context_for_uri(state, uri) + context === nothing && return Main safi = successfully_analyzed_file_info(context, uri) isnothing(safi) && return Main curline = Int(pos.line) + 1 @@ -81,6 +73,21 @@ function find_file_module(state::ServerState, uri::URI, pos::Position) return curmod end +function find_context_for_uri(state::ServerState, uri::URI) + haskey(state.contexts, uri) || return nothing + contexts = state.contexts[uri] + contexts isa ExternalContext && return nothing + context = first(contexts) + for ctx in contexts + # prioritize `PackageSourceAnalysisEntry` if exists + if isa(context.entry, PackageSourceAnalysisEntry) + context = ctx + break + end + end + return context +end + # JuliaLowering uses byte offsets; LSP uses lineno and UTF-* character offset. # These functions do the conversion. diff --git a/test/setup.jl b/test/setup.jl index 458f12bb6..5e5e70b57 100644 --- a/test/setup.jl +++ b/test/setup.jl @@ -195,7 +195,7 @@ function withpackage(test_func, pkgname::AbstractString, Pkg.activate(; temp=true, io=devnull) env_setup() - test_func(pkgpath) + return test_func(pkgpath) finally Pkg.activate(old; io=devnull) end @@ -210,7 +210,7 @@ function withscript(test_func, scriptcode::AbstractString; write(scriptpath, scriptcode) Pkg.activate(; temp=true, io=devnull) env_setup() - test_func(scriptpath) + return test_func(scriptpath) finally Pkg.activate(old; io=devnull) end @@ -229,3 +229,19 @@ function get_text_and_positions(text::String) end return join(lines, '\n'), positions end + +function make_DidOpenTextDocumentNotification(uri, text; + languageId = "julia", + version = 1) + return DidOpenTextDocumentNotification(; + params = DidOpenTextDocumentParams(; + textDocument = TextDocumentItem(; + uri, text, languageId, version))) +end + +function make_DidChangeTextDocumentNotification(uri, text, version) + return DidChangeTextDocumentNotification(; + params = DidChangeTextDocumentParams(; + textDocument = VersionedTextDocumentIdentifier(; uri, version), + contentChanges = [TextDocumentContentChangeEvent(; text)])) +end diff --git a/test/test_diagnostics.jl b/test/test_diagnostics.jl index 599856216..7d06c1204 100644 --- a/test/test_diagnostics.jl +++ b/test/test_diagnostics.jl @@ -7,15 +7,6 @@ using JETLS using JETLS: JL, JS using JETLS.LSP -function make_DidOpenTextDocumentNotification(uri, text; - languageId = "julia", - version = 1) - return DidOpenTextDocumentNotification(; - params = DidOpenTextDocumentParams(; - textDocument = TextDocumentItem(; - uri, text, languageId, version))) -end - @testset "syntax error diagnostics" begin # Test with code that has syntax errors scriptcode = """ diff --git a/test/test_signature_help.jl b/test/test_signature_help.jl index af496d1a1..90dd77609 100644 --- a/test/test_signature_help.jl +++ b/test/test_signature_help.jl @@ -22,7 +22,6 @@ module M_sanity i_exist(a,b,c) = 0 end @testset "sanity" begin - @test 1 === n_si(M_sanity, "i_exist(|)") @test 1 === n_si(M_sanity, "i_exist(1,2,3|)") @test 1 === n_si(M_sanity, "i_exist(|1,2,3)") @@ -32,7 +31,6 @@ end @test 0 === n_si(M_sanity, "()|") end - @testset "don't show help in method definitions" begin snippets = [ "function f(|); end", @@ -46,7 +44,6 @@ end end end - module M_filterp f4() = 0 f4(a) = 0 @@ -59,7 +56,6 @@ f1v(a) = 0 f1v(a, args...) = 0 end @testset "filter by number of positional args" begin - @test 5 === n_si(M_filterp, "f4(|)") @test 4 === n_si(M_filterp, "f4(1|)") @test 4 === n_si(M_filterp, "f4(1,|)") @@ -87,7 +83,6 @@ f(;kw1, kw2=2, kw3::Int=3) = 0 f(x; kw2, kw3, kw4, kw5, kw6) = 0 end @testset "filter by names of kwargs" begin - @test 2 === n_si(M_filterk, "f(|)") # pre-semicolon @@ -148,7 +143,6 @@ inner(args...) = 0 outer(args...) = 0 end @testset "nested" begin - active_si(code) = only(siginfos(M_nested, code)).label @test startswith(active_si("outer(0,1,inner(|))"), "inner") @@ -170,6 +164,55 @@ end @test 1 === n_si(M_invalid, "f1(,,,,,,,,,|)") @test 1 === n_si(M_invalid, "f1(a b c|)") @test 1 === n_si(M_invalid, "f1(k=|)") - end + +include("setup.jl") + +@testset "signature help request/response cycle" begin + script_code = """ + foo(xxx) = :xxx + foo(xxx, yyy) = :xxx_yyy + """ + withscript(script_code) do script_path + uri = string(JETLS.URIs2.filepath2uri(script_path)) + withserver() do (; writereadmsg, id_counter) + # run the full analysis first + (; raw_res) = writereadmsg(make_DidOpenTextDocumentNotification(uri, script_code)) + @test raw_res isa PublishDiagnosticsNotification + @test raw_res.params.uri == uri + + edited_code = """ + foo(xxx) = :xxx + foo(xxx, yyy) = :xxx_yyy + foo(nothing,) # <- cursor set at `,` + """ + (; raw_res) = writereadmsg(make_DidChangeTextDocumentNotification(uri, edited_code, #=version=#2)) + @test raw_res isa PublishDiagnosticsNotification + @test raw_res.params.uri == uri + + let id = id_counter[] += 1 + (; raw_res) = writereadmsg(SignatureHelpRequest(; + id, + params = SignatureHelpParams(; + textDocument = TextDocumentIdentifier(; uri), + position = Position(; line=2, character=12)))) + @test raw_res isa SignatureHelpResponse + @test length(raw_res.result.signatures) == 2 + @test any(raw_res.result.signatures) do siginfo + siginfo.label == "foo(xxx)" && + # this also tests that JETLS doesn't show the nonsensical `var"..."` + # string caused by JET's internal details + occursin("@ Main $(script_path):1", siginfo.documentation) + end + @test any(raw_res.result.signatures) do siginfo + siginfo.label == "foo(xxx, yyy)" && + # this also tests that JETLS doesn't show the nonsensical `var"..."` + # string caused by JET's internal details + occursin("@ Main $(script_path):2", siginfo.documentation) + end + end + end + end end + +end # module test_signature_help From 11f9739b3f21002785beb27d89625242ac8d3d05 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Tue, 3 Jun 2025 22:30:44 +0900 Subject: [PATCH 10/15] support dymamic registration of signature help --- src/JETLS.jl | 34 +++++++++++++++++++++++++++++----- src/LSP/capabilities.jl | 8 ++++---- src/completions.jl | 5 +---- src/signature-help.jl | 21 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/JETLS.jl b/src/JETLS.jl index 31bbc2b50..2764d2905 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -118,6 +118,10 @@ struct Server{Callback} end end +const DEFAULT_DOCUMENT_SELECTOR = DocumentFilter[ + DocumentFilter(; language = "julia") +] + include("utils.jl") include("registration.jl") include("completions.jl") @@ -318,12 +322,22 @@ function handle_InitializeRequest(server::Server, msg::InitializeRequest) :textDocument, :completion, :dynamicRegistration) !== true completionProvider = completion_options() if JETLS_DEV_MODE - @info "Registering completion with `InitializeResponse`" + @info "Registering 'textDocument/completion' with `InitializeResponse`" end else completionProvider = nothing # will be registered dynamically end + if getpath(params.capabilities, + :textDocument, :signatureHelp, :dynamicRegistration) !== true + signatureHelpProvider = signature_help_options() + if JETLS_DEV_MODE + @info "Registering 'textDocument/signatureHelp' with `InitializeResponse`" + end + else + signatureHelpProvider = nothing # will be registered dynamically + end + result = InitializeResult(; capabilities = ServerCapabilities(; positionEncoding = PositionEncodingKind.UTF16, @@ -333,9 +347,7 @@ function handle_InitializeRequest(server::Server, msg::InitializeRequest) save = SaveOptions(; includeText = true)), completionProvider, - signatureHelpProvider = SignatureHelpOptions(; - triggerCharacters = ["(", ",", ";"], - retriggerCharacters = ["."]), + signatureHelpProvider, ), serverInfo = (; name = "JETLS", @@ -364,7 +376,7 @@ function handle_InitializedNotification(server::Server) :textDocument, :completion, :dynamicRegistration) === true push!(registrations, completion_registration()) if JETLS_DEV_MODE - @info "Dynamically registering completion upon `InitializedNotification`" + @info "Dynamically registering 'textDocument/completion' upon `InitializedNotification`" end else # NOTE If completion's `dynamicRegistration` is not supported, @@ -372,6 +384,18 @@ function handle_InitializedNotification(server::Server) # since `CompletionRegistrationOptions` does not extend `StaticRegistrationOptions`. end + if getpath(state.init_params.capabilities, + :textDocument, :signatureHelp, :dynamicRegistration) === true + push!(registrations, signature_help_registration()) + if JETLS_DEV_MODE + @info "Dynamically registering 'textDocument/signatureHelp' upon `InitializedNotification`" + end + else + # NOTE If completion's `dynamicRegistration` is not supported, + # it needs to be registered along with initialization in the `InitializeResponse`, + # since `SignatureHelpRegistrationOptions` does not extend `StaticRegistrationOptions`. + end + register(server, registrations) end diff --git a/src/LSP/capabilities.jl b/src/LSP/capabilities.jl index 2b6bb54d3..b4cc363d7 100644 --- a/src/LSP/capabilities.jl +++ b/src/LSP/capabilities.jl @@ -82,10 +82,10 @@ end # """ # hover::Union{HoverClientCapabilities, Nothing} = nothing - # """ - # Capabilities specific to the `textDocument/signatureHelp` request. - # """ - # signatureHelp::Union{SignatureHelpClientCapabilities, Nothing} = nothing + """ + Capabilities specific to the `textDocument/signatureHelp` request. + """ + signatureHelp::Union{SignatureHelpClientCapabilities, Nothing} = nothing # """ # Capabilities specific to the `textDocument/declaration` request. diff --git a/src/completions.jl b/src/completions.jl index 9a0412fe8..2d7369c02 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -23,14 +23,11 @@ const COMPLETION_REGISTRATION_METHOD = "textDocument/completion" function completion_registration() (; triggerCharacters, resolveProvider, completionItem) = completion_options() - documentSelector = DocumentFilter[ - DocumentFilter(; language = "julia") - ] return Registration(; id = COMPLETION_REGISTRATION_ID, method = COMPLETION_REGISTRATION_METHOD, registerOptions = CompletionRegistrationOptions(; - documentSelector, + documentSelector = DEFAULT_DOCUMENT_SELECTOR, triggerCharacters, resolveProvider, completionItem)) diff --git a/src/signature-help.jl b/src/signature-help.jl index 78ad757c0..8d87fdb1b 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -1,6 +1,27 @@ using .JS using .JL +# initialization +# ============== + +signature_help_options() = SignatureHelpOptions(; + triggerCharacters = ["(", ",", ";"], + retriggerCharacters = ["."]) + +const SIGNATURE_HELP_REGISTRATION_ID = "jetls-signature-help" +const SIGNATURE_HELP_REGISTRATION_METHOD = "textDocument/signatureHelp" + +function signature_help_registration() + (; triggerCharacters, retriggerCharacters) = signature_help_options() + return Registration(; + id = SIGNATURE_HELP_REGISTRATION_ID, + method = SIGNATURE_HELP_REGISTRATION_METHOD, + registerOptions = SignatureHelpRegistrationOptions(; + documentSelector = DEFAULT_DOCUMENT_SELECTOR, + triggerCharacters, + retriggerCharacters)) +end + # utils # ===== From 3f05750d0d76e56ad3c012cd89a082d4c8922594 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Tue, 3 Jun 2025 22:59:22 +0900 Subject: [PATCH 11/15] add commented out dynamic registrations hack --- src/completions.jl | 6 ++++++ src/signature-help.jl | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/completions.jl b/src/completions.jl index 2d7369c02..9a847e760 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -33,6 +33,12 @@ function completion_registration() completionItem)) end +# For dynamic registrations during development +# unregister(currently_running, Unregistration(; +# id=COMPLETION_REGISTRATION_ID, +# method=COMPLETION_REGISTRATION_METHOD)) +# register(currently_running, completion_registration()) + # completion utils # ================ diff --git a/src/signature-help.jl b/src/signature-help.jl index 8d87fdb1b..fd820452f 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -22,6 +22,12 @@ function signature_help_registration() retriggerCharacters)) end +# For dynamic registrations during development +# unregister(currently_running, Unregistration(; +# id=SIGNATURE_HELP_REGISTRATION_ID, +# method=SIGNATURE_HELP_REGISTRATION_METHOD)) +# register(currently_running, signature_help_registration()) + # utils # ===== From 11f708a8d7a3acddf6cc27bfc9d5f7f032fd7a17 Mon Sep 17 00:00:00 2001 From: Em Chu <61633163+mlechu@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:34:03 -0700 Subject: [PATCH 12/15] Apply suggestions from code review Thanks! Co-authored-by: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> --- src/signature-help.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index fd820452f..bb69919b3 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -41,8 +41,8 @@ function resolve_property(mod::Module, st0::JL.SyntaxTree) # Would otherwise throw an unhelpful error. Is this true of all leaf nodes? @assert JL.hasattr(st0, :name_val) s = Symbol(st0.name_val) - !isdefined(mod, s) && return nothing - return getproperty(mod, s) + !(@invokelatest isdefinedglobal(mod, s)) && return nothing + return @invokelatest getglobal(mod, s) elseif kind(st0) === K"." @assert JS.numchildren(st0) === 2 lhs = resolve_property(mod, st0[1]) @@ -64,7 +64,7 @@ function flatten_args(call::JL.SyntaxTree) usable = (arg::JL.SyntaxTree) -> kind(arg) != K"error" orig = filter(usable, JS.children(call)[2:end]) - args = JL.SyntaxTree[] + args = typeof(orig)[] kw_i = 1 for i in eachindex(orig) iskw = kind(orig[i]) === K"parameters" From 8852b815b0143cf265f6298441d3e312a136d64c Mon Sep 17 00:00:00 2001 From: Em Chu Date: Tue, 3 Jun 2025 11:53:18 -0700 Subject: [PATCH 13/15] Use `JL.SyntaxList` instead of `Vector{JL.SyntaxTree}` They tend to show up anyway, so I'll stop trying to fight them. We might want more type params. --- src/signature-help.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index bb69919b3..3411e9f08 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -64,7 +64,7 @@ function flatten_args(call::JL.SyntaxTree) usable = (arg::JL.SyntaxTree) -> kind(arg) != K"error" orig = filter(usable, JS.children(call)[2:end]) - args = typeof(orig)[] + args = JL.SyntaxList(orig.graph) kw_i = 1 for i in eachindex(orig) iskw = kind(orig[i]) === K"parameters" @@ -109,7 +109,7 @@ signatures. If `sig`, then K"=" trees before the semicolon should be interpreted as optional positional args instead of kwargs. """ -function find_kws(args::Vector{JL.SyntaxTree}, kw_i::Int; sig=false) +function find_kws(args::JL.SyntaxList, kw_i::Int; sig=false) out = Dict{String, Int}() for i in (sig ? (kw_i:lastindex(args)) : eachindex(args)) (kind(args[i]) != K"=") && i < kw_i && continue @@ -132,7 +132,7 @@ Information from one call's arguments for filtering signatures. TODO: types """ struct CallArgs - args::Vector{JL.SyntaxTree} + args::JL.SyntaxList kw_i::Int pos_map::Dict{Int, Tuple{Int, Union{Int, Nothing}}} pos_args_lb::Int From 5efb6c15e1c236b5dc47cbe30dd879baf3acd8fe Mon Sep 17 00:00:00 2001 From: Em Chu Date: Tue, 3 Jun 2025 15:17:24 -0700 Subject: [PATCH 14/15] Don't filter on keyword arg if the cursor could be editing its name --- src/signature-help.jl | 27 +++++++++++++++------------ test/test_signature_help.jl | 13 ++++++++++--- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index 3411e9f08..caa033ab0 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -5,7 +5,7 @@ using .JL # ============== signature_help_options() = SignatureHelpOptions(; - triggerCharacters = ["(", ",", ";"], + triggerCharacters = ["(", ",", ";", "\"", "="], retriggerCharacters = ["."]) const SIGNATURE_HELP_REGISTRATION_ID = "jetls-signature-help" @@ -108,13 +108,15 @@ signatures. If `sig`, then K"=" trees before the semicolon should be interpreted as optional positional args instead of kwargs. + +Keywords should be ignored if `cursor` is within the keyword's name. """ -function find_kws(args::JL.SyntaxList, kw_i::Int; sig=false) +function find_kws(args::JL.SyntaxList, kw_i::Int; sig=false, cursor::Int=-1) out = Dict{String, Int}() for i in (sig ? (kw_i:lastindex(args)) : eachindex(args)) (kind(args[i]) != K"=") && i < kw_i && continue n = kwname(args[i]; sig) - if !isnothing(n) + if !isnothing(n) && !(JS.first_byte(n) <= cursor <= JS.last_byte(n) + 1) out[n.name_val] = i end end @@ -126,8 +128,9 @@ Information from one call's arguments for filtering signatures. - args: Every valid child of the K"call" and its K"parameters" if present - kw_i: One plus the number of args not in K"parameters" (semicolon) - pos_map: Map from position in `args` to (min, max) possible positional arg - e.g. f(a, k=1, b..., c) --> a => (1, 1), b => (2, nothing), c => (2, nothing) -- kw_map: kwname => position in `args` + e.g. f(a, k=1, b..., c) + --> a => (1, 1), b => (2, nothing), c => (2, nothing) +- kw_map: kwname => position in `args`. Excludes any WIP kw (see find_kws) TODO: types """ @@ -140,7 +143,8 @@ struct CallArgs kw_map::Dict{String, Int} end -function CallArgs(st0::JL.SyntaxTree) +function CallArgs(st0::JL.SyntaxTree, cursor::Int) + @assert !(-1 in JS.byte_range(st0)) args, kw_i = flatten_args(st0) pos_map = Dict{Int, Tuple{Int, Union{Int, Nothing}}}() lb = 0; ub = 0 @@ -154,7 +158,7 @@ function CallArgs(st0::JL.SyntaxTree) pos_map[i] = (lb, ub) end end - kw_map = find_kws(args, kw_i; sig=false) + kw_map = find_kws(args, kw_i; sig=false, cursor) CallArgs(args, kw_i, pos_map, lb, ub, kw_map) end @@ -226,10 +230,9 @@ function make_siginfo(m::Method, ca::CallArgs, active_arg::Union{Int, Symbol}, p mstr[1:lb], string(strip(mstr[lb+1:end])) end - # We could show the full docs, but there isn't(?) a way to separate by - # method (or resolve items lazily like completions), so we would be sending - # many copies. The user may have seen this already in the completions UI, - # too. + # We could show the full docs, but there isn't a way to resolve items lazily + # like completions, so we might be sending many copies. The user may have + # seen this already in the completions UI, too. # documentation = MarkupContent(; # kind = MarkupKind.Markdown, # value = string(Base.Docs.doc(Base.Docs.Binding(m.var"module", m.name)))) @@ -301,7 +304,7 @@ function cursor_siginfos(mod::Module, ps::JS.ParseStream, b::Int, postprocessor: fn = resolve_property(mod, call[1]) !isa(fn, Function) && return out - ca = CallArgs(call) + ca = CallArgs(call, b) # Influence parameter highlighting by selecting the active argument (which # may be mapped to a parameter in make_siginfo). If cursor is after all diff --git a/test/test_signature_help.jl b/test/test_signature_help.jl index 90dd77609..778ef4332 100644 --- a/test/test_signature_help.jl +++ b/test/test_signature_help.jl @@ -90,15 +90,22 @@ end @test 1 === n_si(M_filterk, "f(kw1=1|)") # post-semicolon - @test 1 === n_si(M_filterk, "f(;kw1|)") + @test 1 === n_si(M_filterk, "f(|;kw1)") + @test 1 === n_si(M_filterk, "f(;kw1,|)") @test 1 === n_si(M_filterk, "f(;kw1=|)") @test 1 === n_si(M_filterk, "f(;kw1=1|)") # mix @test 2 === n_si(M_filterk, "f(kw2=2,kw3=3;|)") @test 2 === n_si(M_filterk, "f(kw2=2; kw3=3|)") - @test 2 === n_si(M_filterk, "f(kw2=2; kw3|)") - @test 1 === n_si(M_filterk, "f(kw2=2; kw6|)") + @test 1 === n_si(M_filterk, "f(kw2=2; kw6=6|)") + + # don't filter on a kw if the cursor could be editing it + @test 2 === n_si(M_filterk, "f(;kw1|)") + @test 2 === n_si(M_filterk, "f(;kw1|=1)") + @test 2 === n_si(M_filterk, "f(;kw|1)") + @test 2 === n_si(M_filterk, "f(;|kw1)") + @test 1 === n_si(M_filterk, "f(;kw1=1, kw1|)") end module M_highlight From 110225e2baa4c48d8675207e1f48297e7e81a73b Mon Sep 17 00:00:00 2001 From: Em Chu Date: Tue, 3 Jun 2025 15:27:23 -0700 Subject: [PATCH 15/15] Fix vararg off-by-one causing highlighting failure --- src/signature-help.jl | 3 ++- test/test_signature_help.jl | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/signature-help.jl b/src/signature-help.jl index caa033ab0..b7fcf6eb0 100644 --- a/src/signature-help.jl +++ b/src/signature-help.jl @@ -130,6 +130,7 @@ Information from one call's arguments for filtering signatures. - pos_map: Map from position in `args` to (min, max) possible positional arg e.g. f(a, k=1, b..., c) --> a => (1, 1), b => (2, nothing), c => (2, nothing) +- pos_args_*: lower and upper bounds on # of positional args - kw_map: kwname => position in `args`. Excludes any WIP kw (see find_kws) TODO: types @@ -151,7 +152,7 @@ function CallArgs(st0::JL.SyntaxTree, cursor::Int) for i in eachindex(args[1:kw_i-1]) if kind(args[i]) === K"..." ub = nothing - pos_map[i] = (lb, ub) + pos_map[i] = (lb + 1, ub) elseif kind(args[i]) != K"=" lb += 1 !isnothing(ub) && (ub += 1) diff --git a/test/test_signature_help.jl b/test/test_signature_help.jl index 778ef4332..85d55162a 100644 --- a/test/test_signature_help.jl +++ b/test/test_signature_help.jl @@ -126,11 +126,13 @@ end @test 3 === ap(M_highlight, "f(0, 1, 2, 3|)") @test 3 === ap(M_highlight, "f(0, 1, 2, 3, 3|)") @test 3 === ap(M_highlight, "f(0, 1, 2, 3, x...|)") + @test 3 === ap(M_highlight, "f(0, 1, 2, x...|)") # splat contains 0 or more args; use what we know @test nothing === ap(M_highlight, "f(x...|, 0, 1, 2, 3, x...)") @test nothing === ap(M_highlight, "f(x..., 0, 1, 2|, 3, x...)") @test 3 === ap(M_highlight, "f(x..., 0, 1, 2, 3|, x...)") @test 3 === ap(M_highlight, "f(x..., 0, 1, 2, 3, x...|)") + @test 3 === ap(M_highlight, "f(x..., 0, 1, 2, |x...)") # various kwarg @test 4 === ap(M_highlight, "f(0, 1, 2, 3; kw4|)")