diff --git a/src/JETLS.jl b/src/JETLS.jl index 1c452c686..2764d2905 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} @@ -117,9 +118,14 @@ struct Server{Callback} end end +const DEFAULT_DOCUMENT_SELECTOR = DocumentFilter[ + DocumentFilter(; language = "julia") +] + include("utils.jl") include("registration.jl") include("completions.jl") +include("signature-help.jl") include("diagnostics.jl") """ @@ -247,6 +253,8 @@ function _handle_message(server::Server, msg) return handle_CompletionRequest(server, msg) elseif msg isa CompletionResolveRequest return handle_CompletionResolveRequest(server, msg) + elseif msg isa SignatureHelpRequest + return handle_SignatureHelpRequest(server, msg) elseif JETLS_DEV_MODE if isdefined(msg, :method) id = getfield(msg, :method) @@ -314,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, @@ -329,6 +347,7 @@ function handle_InitializeRequest(server::Server, msg::InitializeRequest) save = SaveOptions(; includeText = true)), completionProvider, + signatureHelpProvider, ), serverInfo = (; name = "JETLS", @@ -357,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, @@ -365,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 @@ -500,7 +531,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 @@ -528,6 +559,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/LSP.jl b/src/LSP/LSP.jl index b82bd1919..4e0410dac 100644 --- a/src/LSP/LSP.jl +++ b/src/LSP/LSP.jl @@ -17,6 +17,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 1fbd21637..b4cc363d7 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 """ @@ -80,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/LSP/language-features/signature-help.jl b/src/LSP/language-features/signature-help.jl new file mode 100644 index 000000000..4dce72420 --- /dev/null +++ b/src/LSP/language-features/signature-help.jl @@ -0,0 +1,255 @@ +@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.Ty}} = 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 + +@interface SignatureHelpResponse @extends ResponseMessage begin + result::Union{SignatureHelp, Null} +end diff --git a/src/completions.jl b/src/completions.jl index f139d67c8..9a847e760 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -23,19 +23,22 @@ 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)) 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 # ================ @@ -67,53 +70,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 @@ -341,33 +297,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 = false diff --git a/src/signature-help.jl b/src/signature-help.jl new file mode 100644 index 000000000..b7fcf6eb0 --- /dev/null +++ b/src/signature-help.jl @@ -0,0 +1,358 @@ +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 + +# 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 +# ===== + +""" +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) + !(@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]) + return resolve_property(lhs, st0[2]) + end + 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_args(call::JL.SyntaxTree) + if kind(call) === K"where" + return flatten_args(call[1]) + end + @assert kind(call) === K"call" || kind(call) === K"dotcall" + usable = (arg::JL.SyntaxTree) -> kind(arg) != K"error" + orig = filter(usable, JS.children(call)[2:end]) + + args = JL.SyntaxList(orig.graph) + 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 + +""" +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 + elseif kind(a) === K"=" && kind(a[1]) === K"Identifier" + 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 + return nothing +end + +""" +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. + +Keywords should be ignored if `cursor` is within the keyword's name. +""" +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) && !(JS.first_byte(n) <= cursor <= JS.last_byte(n) + 1) + out[n.name_val] = i + end + end + return out +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), 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 +""" +struct CallArgs + args::JL.SyntaxList + 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 + +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 + for i in eachindex(args[1:kw_i-1]) + if kind(args[i]) === K"..." + ub = nothing + pos_map[i] = (lb + 1, ub) + elseif kind(args[i]) != K"=" + lb += 1 + !isnothing(ub) && (ub += 1) + pos_map[i] = (lb, ub) + end + end + kw_map = find_kws(args, kw_i; sig=false, cursor) + 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, 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, 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 + +# 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]+1:label[2]]) + # end + return ParameterInformation(; label, documentation) +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}, postprocessor::JET.PostProcessor=JET.PostProcessor()) + # methodshow prints "f(x::T) [unparseable stuff]" + # parse the first part and put the remainder in documentation + 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])) + end + + # 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)))) + + 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) + + # 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 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"..." + # 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) + 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, 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? + 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? + 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 + # 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 + + 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 + # 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 + arg_i = findfirst(a -> JS.first_byte(a) <= b <= JS.last_byte(a) + 1, ca.args) + 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, postprocessor)) + 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(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) + 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, postprocessor) + activeSignature = nothing + activeParameter = nothing + return send(server, + SignatureHelpResponse(; + id = msg.id, + result = isempty(signatures) ? + null + : SignatureHelp(; + signatures, + activeSignature, + activeParameter))) +end diff --git a/src/utils.jl b/src/utils.jl index b02913a02..2bfd64dd3 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -54,6 +54,40 @@ 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) + 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 + curmod = Main + for (range, mod) in safi.module_range_infos + curline in range || continue + curmod = mod + end + 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. @@ -108,12 +142,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] diff --git a/test/runtests.jl b/test/runtests.jl index 05df30df1..6b47ab57f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -10,6 +10,7 @@ end @testset "utils" include("test_utils.jl") @testset "registration" include("test_registration.jl") @testset "completions" include("test_completions.jl") + @testset "signature help" include("test_signature_help.jl") @testset "analysis" include("test_analysis.jl") @testset "diagnostics" include("test_diagnostics.jl") @testset "full lifecycle" include("test_full_lifecycle.jl") 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 new file mode 100644 index 000000000..85d55162a --- /dev/null +++ b/test/test_signature_help.jl @@ -0,0 +1,227 @@ +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; |)") + + # 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,|)") + + @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=|)") + @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 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 +f(a0, a1, a2, va3...; kw4=0, kw5=0, kws6...) = 0 +end +@testset "param highlighting" begin + 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...|)") + @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|)") + @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 +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 + +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