From 00574738850b5f2ed6ad4afa59389f2dacfbf1ef Mon Sep 17 00:00:00 2001 From: Sebastian Pfitzner Date: Wed, 15 Jul 2026 12:44:06 +0000 Subject: [PATCH] fix: emit signature-help parameter labels as UTF-16 offset ranges Unnamed method arguments (e.g. `get(::Base.EnvDict, k, def)`) were labeled with the internal `#unused#` placeholder, which violates the LSP requirement that a `ParameterInformation.label` be a substring of the signature label. Emit `[start, end)` UTF-16 offset ranges instead, computed positionally from the rendered signature so they stay unambiguous even for identically-rendered parameters. Co-Authored-By: Claude Opus 4.8 --- src/layer_signatures.jl | 43 +++++++++++++++++++++++++++++++++++----- test/test_signatures.jl | 44 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/layer_signatures.jl b/src/layer_signatures.jl index 089e487..a657881 100644 --- a/src/layer_signatures.jl +++ b/src/layer_signatures.jl @@ -13,11 +13,13 @@ Describes a single parameter of a function signature in signature help. -- `label::String`: The parameter's textual label as it appears in the signature. +- `label::Union{String,Tuple{Int,Int}}`: The parameter as it appears in the + signature — either the exact substring, or a `[start, end)` UTF-16 offset range + into the signature label (LSP `ParameterInformation.label`). - `documentation::Union{String,Nothing}`: Optional documentation for the parameter. """ struct ParameterInfo - label::String + label::Union{String,Tuple{Int,Int}} documentation::Union{String,Nothing} end @@ -132,14 +134,45 @@ end _sig_type_str(@nospecialize(t), pred) = sprint((io, x) -> show(IOContext(io, :ss_shorten => pred), x), t) +# Number of UTF-16 code units in `s`. `ParameterInformation` label offsets are +# counted in UTF-16 code units (LSP spec), matching how the client indexes the +# signature label. +_utf16_length(s::AbstractString) = sum(c -> codepoint(c) >= 0x10000 ? 2 : 1, s; init=0) + +# Text of a single SymbolServer method parameter exactly as it is rendered in +# the signature label by `Base.print(io, ::MethodStore)` under the same +# `:ss_shorten`/`:ss_omit_any` context: `name::type`, `::type` for an unnamed +# (`#unused#`) argument, or just `name` when the `::Any` annotation is omitted. +function _ss_param_text(a, pred) + buf = IOBuffer() + io = IOContext(buf, :ss_shorten => pred) + a[1] === Symbol("#unused#") || print(io, a[1]) + SymbolServer.isfakeany(a[2]) || print(io, "::", a[2]) + return String(take!(buf)) +end + function _get_signatures(b::T, tls::StaticLint.Scope, sigs::Vector{SignatureInfo}, env, meta_dict) where T <: Union{SymbolServer.FunctionStore,SymbolServer.DataTypeStore} pred = _sig_shorten_pred(env) StaticLint.iterate_over_ss_methods(b, tls, env, function (m) label = sprint((io, x) -> print(IOContext(io, :ss_shorten => pred, :ss_omit_any => true), x), m) - params = [ParameterInfo( - string(a[1]), + # `ParameterInformation.label` is a `[start, end)` UTF-16 offset range + # into the signature label (LSP spec). The label starts with `name(` and + # joins parameters with `, `, so each parameter's span follows from the + # accumulated rendered widths — a positional map, so it can never emit + # the `#unused#` placeholder and stays unambiguous even when two + # parameters render identically (e.g. `::Any, ::Any`). + off = _utf16_length(string(m.name)) + 1 # advance past "name(" + n = length(m.sig) + params = ParameterInfo[] + for (i, a) in enumerate(m.sig) + w = _utf16_length(_ss_param_text(a, pred)) + push!(params, ParameterInfo( + (off, off + w), SymbolServer.isfakeany(a[2]) ? "" : _sig_type_str(a[2], pred) - ) for a in m.sig] + )) + off += w + i == n || (off += 2) # ", " separator + end push!(sigs, SignatureInfo(label, "", params)) return false end) diff --git a/test/test_signatures.jl b/test/test_signatures.jl index be45419..803ceda 100644 --- a/test/test_signatures.jl +++ b/test/test_signatures.jl @@ -212,7 +212,7 @@ end @test occursin("identity(x) in Base", s.label) @test !occursin("Core.Any", s.label) @test !occursin("::", split(s.label, " in ")[1]) - @test s.parameters[1].label == "x" + @test s.parameters[1].label == (9, 10) # the `x` in `identity(x)` @test s.parameters[1].documentation == "" # `print(io::IO, ...)` — the exported `IO` type must render without its @@ -224,6 +224,48 @@ end @test any(s -> any(p -> p.documentation == "IO", s.parameters), psigs) end +@testitem "Signatures: parameter labels are offset ranges into the signature" begin + using JuliaWorkspaces: JuliaWorkspace, add_file!, TextFile, SourceText, get_signature_help + using JuliaWorkspaces.URIs2: URI + + # `get` has stdlib methods with unnamed arguments (e.g. `get(::Base.EnvDict, + # k, def)`). The LSP spec requires each parameter label to be either a + # substring of the signature label or a `[start, end)` UTF-16 offset range + # into it — never the internal placeholder `#unused#`. We emit offset ranges, + # so each range must select exactly the parameter's text. + source = """ + get( + """ + + jw = JuliaWorkspace() + uri = URI("file:///sigunused/test.jl") + add_file!(jw, TextFile(uri, SourceText(source, "julia"))) + + # UTF-16 code-unit slice of `s` for a 0-based `[start, end)` range. The test + # signatures are ASCII, so this coincides with a plain character slice. + function utf16_slice(s, range) + units = Char[] + for c in s + push!(units, c) + codepoint(c) >= 0x10000 && push!(units, c) # surrogate pair filler + end + return String(units[(range[1] + 1):range[2]]) + end + + result = get_signature_help(jw, uri, ncodeunits("get(")) + @test !isempty(result.signatures) + params = [(sig.label, p) for sig in result.signatures for p in sig.parameters] + for (label, p) in params + @test p.label isa Tuple{Int,Int} + start, stop = p.label + @test 0 <= start <= stop + @test !occursin("#unused#", utf16_slice(label, p.label)) + end + # An unnamed argument selects a leading `::Type`; `get` has such methods + # (e.g. `get(::Base.EnvDict, k, def)`), which used to be labeled `#unused#`. + @test any(((label, p),) -> startswith(utf16_slice(label, p.label), "::"), params) +end + @testitem "Signatures: function with var\"\" argument (#3867)" begin using JuliaWorkspaces: JuliaWorkspace, add_file!, TextFile, SourceText, get_signature_help using JuliaWorkspaces.URIs2: URI