From a017f1bfc5c7c10dba89869ae8ff2f09071f3b46 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:40:05 +1000 Subject: [PATCH 01/13] completions: guard keyword documentation with haskey A keyword absent from Base.Docs.keywords threw a KeyError while building the completion table; look it up defensively and fall back to no documentation. --- src/completions.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/completions.jl b/src/completions.jl index 417f433ce..66b9fbebc 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -623,7 +623,12 @@ const KEYWORD_DOCS = Dict{String,MarkupContent}() let keywords = Set{String}() union!(keywords, REPL.REPLCompletions.sorted_keywords, REPL.REPLCompletions.sorted_keyvals) for keyword in keywords - documentation = get_keyword_doc(Symbol(keyword)) + keysym = Symbol(keyword) + documentation = if keysym in (Symbol("true"), Symbol("false")) || haskey(Base.Docs.keywords, keysym) + get_keyword_doc(keysym) + else + nothing + end KEYWORD_COMPLETIONS[keyword] = CompletionItem(; label = keyword, labelDetails = CompletionItemLabelDetails(; description = "keyword"), From bffbf94394d8358dfa41fe23a1c5b094dc9a47d4 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:40:05 +1000 Subject: [PATCH 02/13] analysis: stop forwarding refresh_local_cache to the toplevel analyzer JET v0.11 refreshes the local cache inside the constructor instead of accepting it as a keyword, so passing it raised a MethodError on every analysis. --- src/analysis/Interpreter.jl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/analysis/Interpreter.jl b/src/analysis/Interpreter.jl index 1c858d8ac..ffe9079d5 100644 --- a/src/analysis/Interpreter.jl +++ b/src/analysis/Interpreter.jl @@ -65,15 +65,13 @@ end JET.ToplevelAbstractAnalyzer(interp::LSInterpreter) = interp.analyzer function JET.ToplevelAbstractAnalyzer( interp::LSInterpreter, concretized::BitVector; - refresh_local_cache::Bool = true, # This option is used by JET v0.10. TODO We can remove this once we update JET to v0.11. reset_report_target_modules::Bool = true, # LSInterpreter specific option ) if reset_report_target_modules reset_report_target_modules!(interp.analyzer, JET.InterpretationState(interp).res.analyzed_files) end return @invoke JET.ToplevelAbstractAnalyzer( - interp::JET.ConcreteInterpreter, concretized::BitVector; - refresh_local_cache) + interp::JET.ConcreteInterpreter, concretized::BitVector) end # overloads From 3039651021d92fa6f7e5a8cd21cbe3c6fa458b31 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:40:05 +1000 Subject: [PATCH 03/13] analysis: gate InferenceState.world for the 1.14 dev Compiler The dev Compiler removed the world field. On the nightly, derive the world via get_inference_world, hand binding_world_hints to the partition scan, and call the three-argument update_valid_age!; older Julia keeps sv.world. --- src/analysis/Analyzer.jl | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/analysis/Analyzer.jl b/src/analysis/Analyzer.jl index 594b8e8d1..941b0ba3c 100644 --- a/src/analysis/Analyzer.jl +++ b/src/analysis/Analyzer.jl @@ -387,16 +387,27 @@ function CC.abstract_eval_globalref( if saw_latestworld return CC.RTEffects(Any, Any, CC.generic_getglobal_effects) end - (valid_worlds, ret) = CC.scan_leaf_partitions(analyzer, g, sv.world) do analyzer::LSAnalyzer, binding::Core.Binding, partition::Core.BindingPartition + @static if hasfield(CC.InferenceState, :world) + curworld = sv.world.this + worldhint = sv.world + else + curworld = CC.get_inference_world(analyzer) + worldhint = CC.binding_world_hints(curworld, sv) + end + (valid_worlds, ret) = CC.scan_leaf_partitions(analyzer, g, worldhint) do analyzer::LSAnalyzer, binding::Core.Binding, partition::Core.BindingPartition offset = report_offset(analyzer, sv) if offset ≤ allowed_offset - if partition.min_world ≤ sv.world.this ≤ partition.max_world # XXX This should probably be fixed on the Julia side + if partition.min_world ≤ curworld ≤ partition.max_world # XXX This should probably be fixed on the Julia side report_undef_global_var!(analyzer, sv, binding, partition, offset) end end CC.abstract_eval_partition_load(analyzer, binding, partition) end - CC.update_valid_age!(sv, valid_worlds) + @static if hasfield(CC.InferenceState, :world) + CC.update_valid_age!(sv, valid_worlds) + else + CC.update_valid_age!(sv, curworld, valid_worlds) + end return ret end From 30ed199048f1c8c89d15245ca26fa154bfe0671c Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:22:22 +1000 Subject: [PATCH 04/13] Fix MethodError from stale refresh_local_cache kwarg at call site The LSInterpreter ToplevelAbstractAnalyzer method dropped the refresh_local_cache kwarg, but the concurrent signature-analysis call site still passed it, so every task threw MethodError on all Julia versions. The cache is still refreshed via JET's positional default in the 2-arg ToplevelAbstractAnalyzer(::ConcreteInterpreter, ::BitVector). Co-Authored-By: Claude Opus 4.8 --- src/analysis/Interpreter.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/analysis/Interpreter.jl b/src/analysis/Interpreter.jl index ffe9079d5..2351b39e9 100644 --- a/src/analysis/Interpreter.jl +++ b/src/analysis/Interpreter.jl @@ -178,8 +178,7 @@ function JET.analyze_from_definitions!(interp::LSInterpreter, config::JET.Toplev # Create a new analyzer with fresh local caches (`inf_cache` and `analysis_results`) # to avoid data races between concurrent signature analysis tasks analyzer = JET.ToplevelAbstractAnalyzer(interp, JET.non_toplevel_concretized; - reset_report_target_modules = false, - refresh_local_cache = true) + reset_report_target_modules = false) inf_world = CC.get_inference_world(analyzer) match = Base._which(tt; # NOTE use the latest world counter with `method_table(analyzer)` unwrapped, From 3d8866ac06dbc88ca3e4e3ce02462dceb1755a93 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:06:28 +1000 Subject: [PATCH 05/13] Mask nothelix notebook non-code cells before parsing @markdown/@raw/@typst cell bodies are prose, not Julia; parsing them produced bogus syntax diagnostics. Blank them out byte-for-byte (offsets and line numbers preserved) before JuliaSyntax sees the text. Files without cell markers pass through untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Js76HSq1MpJgHhekLahoFH --- src/document-synchronization.jl | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/document-synchronization.jl b/src/document-synchronization.jl index 6ac8b974f..70207bba1 100644 --- a/src/document-synchronization.jl +++ b/src/document-synchronization.jl @@ -1,9 +1,60 @@ function ParseStream!(s::Union{AbstractString,Vector{UInt8}}) - stream = JS.ParseStream(s) + stream = JS.ParseStream(mask_noncode_cells(s)) JS.parse!(stream; rule=:all) return stream end +const NONCODE_CELL_MARKERS = ("@markdown", "@raw", "@typst") + +function cell_marker_kind(line::AbstractString) + for marker in NONCODE_CELL_MARKERS + if startswith(line, marker) + rest = SubString(line, ncodeunits(marker) + 1) + (isempty(rest) || first(rest) in (' ', '\t', '\r', '\n')) && return :noncode + end + end + if startswith(line, "@cell") + rest = SubString(line, ncodeunits("@cell") + 1) + (isempty(rest) || first(rest) in (' ', '\t', '\r', '\n')) && return :code + end + return :none +end + +""" + mask_noncode_cells(s) -> Union{typeof(s), String} + +Blank out the body of nothelix notebook `@markdown`/`@raw`/`@typst` cells +before parsing, so prose that is not comment-prefixed never produces Julia +diagnostics. Each masked line is replaced with the same number of space bytes, +keeping every byte offset and line number in the rest of the file identical. +Files without cell markers are returned untouched. +""" +function mask_noncode_cells(s::Union{AbstractString,Vector{UInt8}}) + text = s isa Vector{UInt8} ? String(copy(s)) : s + any_marker = false + masking = false + io = IOBuffer() + for line in eachline(IOBuffer(text); keep=true) + kind = cell_marker_kind(line) + if kind === :noncode + any_marker = true + masking = true + write(io, line) + elseif kind === :code + any_marker = true + masking = false + write(io, line) + elseif masking + for b in codeunits(line) + write(io, (b == UInt8('\n') || b == UInt8('\r')) ? b : UInt8(' ')) + end + else + write(io, line) + end + end + return any_marker ? String(take!(io)) : s +end + # Drop every per-file cache entry for `uri`. Called whenever a file's content # changes (didChange/didOpen, notebook cell edits, watched-file events). # Full-analysis updates invalidate semantic caches separately because not all From 1cb280c80c02097b42c9ddfced7e6831971ecdb8 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:07:15 +1000 Subject: [PATCH 06/13] compat: gate ConstCallInfo and InferenceCache for the pinned 1.14 Compiler Upstream's report_method_error! unwraps CC.ConstCallInfo and ASTTypeAnnotator carries a Vector{InferenceResult} local cache; the pinned Compiler (2e0a778b) removed ConstCallInfo and replaced the inference-cache convention with CC.InferenceCache. Gate both behind isdefined checks so either Compiler builds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Js76HSq1MpJgHhekLahoFH --- src/analysis/Analyzer.jl | 6 ++++-- src/analysis/TypeAnnotation.jl | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/analysis/Analyzer.jl b/src/analysis/Analyzer.jl index 941b0ba3c..dcddc0ca1 100644 --- a/src/analysis/Analyzer.jl +++ b/src/analysis/Analyzer.jl @@ -847,8 +847,10 @@ function report_method_error!( arginfo::CC.ArgInfo, @nospecialize(atype) ) info = call.info - if isa(info, CC.ConstCallInfo) - info = info.call + @static if isdefined(CC, :ConstCallInfo) + if isa(info, CC.ConstCallInfo) + info = info.call + end end if isa(info, CC.MethodMatchInfo) report_method_error!(analyzer, sv, info, atype) diff --git a/src/analysis/TypeAnnotation.jl b/src/analysis/TypeAnnotation.jl index 2e3ce7a53..c86fc5ee3 100644 --- a/src/analysis/TypeAnnotation.jl +++ b/src/analysis/TypeAnnotation.jl @@ -191,11 +191,17 @@ mutable struct OCBodyAnnotationState end OCBodyAnnotationState() = OCBodyAnnotationState(nothing) +@static if isdefined(CC, :InferenceCache) + const ASTLocalInferenceCache = CC.InferenceCache +else + const ASTLocalInferenceCache = Vector{CC.InferenceResult} +end + struct ASTTypeAnnotator <: CC.AbstractInterpreter world::UInt inf_params::CC.InferenceParams opt_params::CC.OptimizationParams - inf_cache::Vector{CC.InferenceResult} + inf_cache::ASTLocalInferenceCache toptree::SyntaxTreeC topmi::MethodInstance @@ -230,7 +236,7 @@ struct ASTTypeAnnotator <: CC.AbstractInterpreter aggressive_constant_propagation = true ), opt_params::CC.OptimizationParams = CC.OptimizationParams(), - inf_cache::Vector{CC.InferenceResult} = CC.InferenceResult[], + inf_cache::ASTLocalInferenceCache = ASTLocalInferenceCache(), oc_body_trees::IdDict{Method,SyntaxTreeC} = IdDict{Method,SyntaxTreeC}(), oc_body_annotation_states::IdDict{Method,OCBodyAnnotationState} = IdDict{Method,OCBodyAnnotationState}(), oc_argtype_observations::OCArgtypeTable = OCArgtypeTable(), @@ -974,7 +980,7 @@ function _infer_toplevel_tree( world::UInt = Base.get_world_counter() ) filter = SyntheticFilter(st0, ctx3.bindings) - inf_cache = CC.InferenceResult[] + inf_cache = ASTLocalInferenceCache() interp = refinements = nothing for _ = 1:MAX_OC_REFINEMENT_PASSES observations = OCArgtypeTable() @@ -1048,7 +1054,7 @@ function infer_lowered_tree( ctx3::JL.VariableAnalysisContext, inferrable_tree3::SyntaxTreeC, context_module::Module, world::UInt, filter::SyntheticFilter, observations::OCArgtypeTable, refinements::Union{Nothing,OCArgtypeTable}, - inf_cache::Vector{CC.InferenceResult} + inf_cache::ASTLocalInferenceCache ) inferrable_tree = try # Route single-method local closures through `OpaqueClosure` instead of @@ -1090,7 +1096,7 @@ function infer_thunk!( tree::SyntaxTreeC, src::CodeInfo, context_module::Module, argtypes::Union{Nothing,Vector{Any}}, world::UInt, filter::SyntheticFilter, observations::OCArgtypeTable, refinements::Union{Nothing,OCArgtypeTable}, - inf_cache::Vector{CC.InferenceResult} + inf_cache::ASTLocalInferenceCache ) strip_latestworld!(src) mi = construct_toplevel_mi(src, context_module) @@ -1152,7 +1158,7 @@ end function infer_method_defs!( inferred::SyntaxTreeC, src::CodeInfo, context_module::Module, world::UInt, filter::SyntheticFilter, observations::OCArgtypeTable, refinements::Union{Nothing,OCArgtypeTable}, - inf_cache::Vector{CC.InferenceResult} + inf_cache::ASTLocalInferenceCache ) block = inferred[1] nstmts = JS.numchildren(block) From fd15c6eb299c99b2e1ba2be37a340ca0ca8e250e Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:07:15 +1000 Subject: [PATCH 07/13] annotation: fix pinned-Compiler API drift and unwrap non-const binding states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finishinfer! grew an opt_cache parameter in the pinned Compiler, so the annotator's 3-arg overload never dispatched and no :type attributes were ever attached — hover, field completions, and inlay hints all silently degraded (upstream's own precompile workload warned). Gate the overload on both arities, gate bb_vartables->bb_states and ConstCallInfo, and teach the annotator's partition load plus resolve_global_const to unwrap the AbstractBindingState JET now materializes for non-const globals, so notebook toplevel bindings hover with concrete types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Js76HSq1MpJgHhekLahoFH --- src/analysis/TypeAnnotation.jl | 62 +++++++++++++++++++++++++----- src/utils/type-annotation-utils.jl | 9 ++++- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/analysis/TypeAnnotation.jl b/src/analysis/TypeAnnotation.jl index c86fc5ee3..760bc1317 100644 --- a/src/analysis/TypeAnnotation.jl +++ b/src/analysis/TypeAnnotation.jl @@ -308,7 +308,26 @@ function CC.abstract_eval_partition_load( ) res = @invoke CC.abstract_eval_partition_load( interp::CC.AbstractInterpreter, binding::Core.Binding, partition::Core.BindingPartition) - return concretize_abstract_binding_state_load(interp, res) + res = concretize_abstract_binding_state_load(interp, res) + if res.rt === Any + # Non-const materialized globals load as `Any` (the binding itself is untyped); + # the recorded assignment type lives in the module value. + g = binding.globalref + world = Base.get_world_counter() + if Base.invoke_in_world(world, isdefinedglobal, g.mod, g.name) + val = Base.invoke_in_world(world, getglobal, g.mod, g.name) + if val isa JET.AbstractBindingState && isdefined(val, :typ) + (; exct, effects) = res + if val.maybeundef + ⊔ = CC.join(CC.typeinf_lattice(interp)) + exct = exct ⊔ UndefVarError + effects = CC.Effects(effects; nothrow = exct === Union{}) + end + return CC.RTEffects(val.typ, exct, effects) + end + end + end + return res end # Script-mode full analysis may hand us a context module populated by JET's virtualprocess, @@ -631,14 +650,19 @@ end # - If the same basic block has a slot assignment that dominates `idx`, # use the assigned RHS's type (`ssavaluetypes[pc_assign]`). # - Otherwise fall back to the bb's entry varstate -# (`bb_vartables[bb][id]`), which CC's dataflow has already populated +# (`bb_states[bb].vartable[id]`), which CC's dataflow has already populated # with cross-bb branch narrowing. function slot_type_at(slot::SlotNumber, idx::Int, frame::CC.InferenceState) pc_assign = CC.find_dominating_assignment(slot.id, idx, frame) pc_assign === nothing || return frame.ssavaluetypes[pc_assign] bb = CC.block_for_inst(frame.cfg, idx) - entry = @something frame.bb_vartables[bb] return frame.src.slottypes[slot.id] - return entry[slot.id].typ + @static if hasfield(CC.InferenceState, :bb_states) + entry = @something frame.bb_states[bb] return frame.src.slottypes[slot.id] + return entry.vartable[slot.id].typ + else + entry = @something frame.bb_vartables[bb] return frame.src.slottypes[slot.id] + return entry[slot.id].typ + end end # Extract the matched methods for a call site from CC's per-stmt `CallInfo`. @@ -661,10 +685,14 @@ function collect_call_matches!(matches::Vector{Core.MethodMatch}, @nospecialize for sub in info.split collect_call_matches!(matches, sub) end - elseif info isa CC.ConstCallInfo - # `ConstCallInfo` wraps the underlying dispatch info with a - # const-prop'd result; the same matching methods live one level down. - collect_call_matches!(matches, info.call) + else + @static if isdefined(CC, :ConstCallInfo) + if info isa CC.ConstCallInfo + # `ConstCallInfo` wraps the underlying dispatch info with a + # const-prop'd result; the same matching methods live one level down. + collect_call_matches!(matches, info.call) + end + end end return matches end @@ -815,8 +843,24 @@ function annotate_types!( end end +@static if hasmethod(CC.finishinfer!, + Tuple{CC.InferenceState, CC.AbstractInterpreter, Int, IdDict{MethodInstance, CodeInstance}}) +function CC.finishinfer!(frame::CC.InferenceState, interp::ASTTypeAnnotator, cycleid::Int, + opt_cache::IdDict{MethodInstance, CodeInstance}) + ret = @invoke CC.finishinfer!(frame::CC.InferenceState, interp::CC.AbstractInterpreter, + cycleid::Int, opt_cache::IdDict{MethodInstance, CodeInstance}) + finishinfer_annotate!(frame, interp) + return ret +end +else function CC.finishinfer!(frame::CC.InferenceState, interp::ASTTypeAnnotator, cycleid::Int) ret = @invoke CC.finishinfer!(frame::CC.InferenceState, interp::CC.AbstractInterpreter, cycleid::Int) + finishinfer_annotate!(frame, interp) + return ret +end +end + +function finishinfer_annotate!(frame::CC.InferenceState, interp::ASTTypeAnnotator) if frame.linfo === interp.topmi annotate_types!(interp.toptree[1], frame, interp.filter) else @@ -825,7 +869,7 @@ function CC.finishinfer!(frame::CC.InferenceState, interp::ASTTypeAnnotator, cyc record_oc_body_annotation_candidate!(interp, def, frame) end end - return ret + return nothing end # The pending `def` entry marks the dynamic extent of the eager `check=false` diff --git a/src/utils/type-annotation-utils.jl b/src/utils/type-annotation-utils.jl index f73cf53db..c7389674a 100644 --- a/src/utils/type-annotation-utils.jl +++ b/src/utils/type-annotation-utils.jl @@ -75,7 +75,7 @@ end """ resolve_global_const(context_module::Module, node::SyntaxTreeC, world::UInt) -> - Core.Const | nothing + Core.Const | Type | nothing Best-effort static lookup of a `K"Identifier"` or `K"."` dotted-path node as a `Core.Const` value by walking the dotted path against `context_module`. Used as a fallback @@ -97,7 +97,12 @@ function resolve_global_const(context_module::Module, node::SyntaxTreeC, world:: if JS.kind(node) === JS.K"Identifier" && has_name_val(node) sym = Symbol(name_val(node)) Base.invoke_in_world(world, isdefinedglobal, context_module, sym) || return nothing - return Core.Const(Base.invoke_in_world(world, getglobal, context_module, sym)) + val = Base.invoke_in_world(world, getglobal, context_module, sym) + if val isa JET.AbstractBindingState + isdefined(val, :typ) || return nothing + return val.typ + end + return Core.Const(val) elseif JS.kind(node) === JS.K"." && JS.numchildren(node) == 2 prefix = node[1] suffix = node[2] From 87652d61428bbed669b4a9287d83545b95fa3669 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:08:58 +1000 Subject: [PATCH 08/13] notebook: mask cell markers, keep raw text for formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mask_noncode_cells now blanks the @cell/@markdown/@raw/@typst marker lines and legacy `using NothelixMacros` (byte-length preserving) so the LSP never reports them as undefined macros — no NothelixMacros package needed. FileInfo carries the unmasked raw_text; document_text returns it so whole-document formatting and the test runner see real content while the parse stream stays masked. Fixes :fmt erasing notebook buffers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Js76HSq1MpJgHhekLahoFH --- src/document-synchronization.jl | 39 +++++++++++++++++++++------------ src/types.jl | 11 ++++++++-- src/utils/ast.jl | 3 ++- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/document-synchronization.jl b/src/document-synchronization.jl index 70207bba1..e4e183788 100644 --- a/src/document-synchronization.jl +++ b/src/document-synchronization.jl @@ -20,14 +20,23 @@ function cell_marker_kind(line::AbstractString) return :none end +function blank_line!(io::IO, line::AbstractString) + for b in codeunits(line) + write(io, (b == UInt8('\n') || b == UInt8('\r')) ? b : UInt8(' ')) + end +end + """ mask_noncode_cells(s) -> Union{typeof(s), String} -Blank out the body of nothelix notebook `@markdown`/`@raw`/`@typst` cells -before parsing, so prose that is not comment-prefixed never produces Julia -diagnostics. Each masked line is replaced with the same number of space bytes, -keeping every byte offset and line number in the rest of the file identical. -Files without cell markers are returned untouched. +Blank out nothelix notebook scaffolding before parsing: the `@cell` / +`@markdown` / `@raw` / `@typst` marker lines, the body of non-code cells, and a +legacy `using NothelixMacros` line. The cell macros are plugin-level markers, +not Julia the user wrote, so hiding them stops JETLS reporting them as undefined +macros and removes any need for a `NothelixMacros` package. Each masked line +becomes the same number of space bytes, keeping every byte offset and line +number identical. Code-cell bodies are left intact for analysis. Files without +cell markers are returned untouched. """ function mask_noncode_cells(s::Union{AbstractString,Vector{UInt8}}) text = s isa Vector{UInt8} ? String(copy(s)) : s @@ -39,15 +48,15 @@ function mask_noncode_cells(s::Union{AbstractString,Vector{UInt8}}) if kind === :noncode any_marker = true masking = true - write(io, line) + blank_line!(io, line) elseif kind === :code any_marker = true masking = false - write(io, line) + blank_line!(io, line) elseif masking - for b in codeunits(line) - write(io, (b == UInt8('\n') || b == UInt8('\r')) ? b : UInt8(' ')) - end + blank_line!(io, line) + elseif startswith(lstrip(line), "using NothelixMacros") + blank_line!(io, line) else write(io, line) end @@ -84,9 +93,11 @@ Computes testsetinfos atomically as part of the caching operation, preserving test results from previous testsetinfos where possible. """ cache_file_info!(server::Server, uri::URI, version::Int, text::Union{AbstractString,Vector{UInt8}}) = - cache_file_info!(server, uri, version, ParseStream!(text)) + cache_file_info!(server, uri, version, ParseStream!(text); + raw_text = text isa AbstractString ? String(text) : String(copy(text))) function cache_file_info!( - server::Server, uri::URI, version::Int, parsed_stream::JS.ParseStream + server::Server, uri::URI, version::Int, parsed_stream::JS.ParseStream; + raw_text::Union{Nothing,AbstractString} = nothing ) state = server.state prev_fi = get_file_info(state, uri) @@ -97,7 +108,7 @@ function cache_file_info!( testsetinfos, any_deleted = compute_testsetinfos!(server, st0, prev_testsetinfos) fi = FileInfo(version, parsed_stream, filename, state.encoding, testsetinfos; - syntax_tree0=st0, inferred_context_cache=InferredContextCache()) + syntax_tree0=st0, inferred_context_cache=InferredContextCache(), raw_text) store!(state.file_cache) do cache Base.PersistentDict(cache, uri => fi), nothing end @@ -134,7 +145,7 @@ function handle_DidOpenTextDocumentNotification(server::Server, msg::DidOpenText return mark_text_document_content_opened!(server, uri) # turn on the `opened` flag @assert textDocument.languageId == "julia" parsed_stream = ParseStream!(textDocument.text) - cache_file_info!(server, uri, textDocument.version, parsed_stream) + cache_file_info!(server, uri, textDocument.version, parsed_stream; raw_text = textDocument.text) cache_saved_file_info!(server.state, uri, parsed_stream) invalidate_unsynced_file_cache!(server.state, uri) request_analysis!(server, uri, #=invalidate=#false) diff --git a/src/types.jl b/src/types.jl index a4a4e6663..0e75999da 100644 --- a/src/types.jl +++ b/src/types.jl @@ -118,18 +118,25 @@ struct FileInfo # O(N) line scan once per FileInfo (constructed on didOpen / didChange) is a # net win across the server. line_starts::LineStartsIndex + # Unmasked document text. `parsed_stream.textbuf` may be masked (nothelix + # cell markers blanked before parsing), so whole-document text extraction — + # formatting, test-runner — reads this instead. `nothing` for unsynced / + # disk files, where the (possibly masked) parsed stream is the only source. + raw_text::Union{Nothing,String} function FileInfo( version::Int, parsed_stream::JS.ParseStream, filename::AbstractString, encoding::LSP.PositionEncodingKind.Ty = LSP.PositionEncodingKind.UTF16, testsetinfos::Vector{TestsetInfo} = EMPTY_TESTSETINFOS; syntax_tree0::Union{Nothing,SyntaxTreeC} = nothing, - inferred_context_cache::Union{Nothing,InferredContextCache} = nothing + inferred_context_cache::Union{Nothing,InferredContextCache} = nothing, + raw_text::Union{Nothing,AbstractString} = nothing ) syntax_tree0 = syntax_tree0 === nothing ? nothing : JS.prune(syntax_tree0) line_starts = build_line_starts(parsed_stream.textbuf) new(version, parsed_stream, filename, encoding, testsetinfos, syntax_tree0, - inferred_context_cache, line_starts) + inferred_context_cache, line_starts, + raw_text === nothing ? nothing : String(raw_text)) end end @define_override_constructor FileInfo # For testsetinfos update diff --git a/src/utils/ast.jl b/src/utils/ast.jl index c61be07ac..d915d56a5 100644 --- a/src/utils/ast.jl +++ b/src/utils/ast.jl @@ -28,7 +28,8 @@ name_val(st::SyntaxTreeC) = st.name_val::String var_id(st::SyntaxTreeC) = st.var_id::JL.IdTag get_source_text(ps::JS.ParseStream) = JS.sourcetext(JS.SourceFile(ps)) -document_text(fi::FileInfo) = get_source_text(fi.parsed_stream) +document_text(fi::FileInfo) = + fi.raw_text === nothing ? get_source_text(fi.parsed_stream) : fi.raw_text document_range(fi::FileInfo) = jsobj_to_range(fi.parsed_stream, fi) @static if JL.DEBUG From a6be8d3f55b70f0a1867307c9269bd53dc493553 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:13:39 +1000 Subject: [PATCH 09/13] analysis: analyze_method_signature! returns a bare InferenceResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JET's analyze_method_signature! → analyze_method_instance! returns the InferenceResult directly, not (analyzer, result). The three sites that destructured it as a 2-tuple threw MethodError: no method matching iterate(::InferenceResult), silently killing signature analysis (no method/undef diagnostics fired). Drop the stale `analyzer,` binding — the analyzer is mutated in place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Js76HSq1MpJgHhekLahoFH --- src/analysis/Interpreter.jl | 2 +- src/analysis/full-analysis.jl | 2 +- test/analysis/test_Analyzer.jl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/analysis/Interpreter.jl b/src/analysis/Interpreter.jl index 2351b39e9..b3923f00f 100644 --- a/src/analysis/Interpreter.jl +++ b/src/analysis/Interpreter.jl @@ -192,7 +192,7 @@ function JET.analyze_from_definitions!(interp::LSInterpreter, config::JET.Toplev # redirect keyword-slurping forwarders to the abstract keyword sorter, # but only after the `entrypoint` check above sees the original method name match = JETLS.redirect_keyword_slurp_match(analyzer, match, inf_world) - analyzer, result = JET.analyze_method_signature!(analyzer, + result = JET.analyze_method_signature!(analyzer, match.method, match.spec_types, match.sparams) reports = JET.get_reports(analyzer, result) isempty(reports) || @lock progress.reports_lock append!(progress.reports, reports) diff --git a/src/analysis/full-analysis.jl b/src/analysis/full-analysis.jl index 1ad92df07..10ca2f871 100644 --- a/src/analysis/full-analysis.jl +++ b/src/analysis/full-analysis.jl @@ -1063,7 +1063,7 @@ function analyze_package_with_revise( JET.AnalyzerState(JET.AnalyzerState(analyzer), #=refresh_local_cache=#true)) match = signature_analysis_match(task_analyzer, siginfo.sig, inf_world) if match !== nothing - task_analyzer, result = JET.analyze_method_signature!(task_analyzer, + result = JET.analyze_method_signature!(task_analyzer, match.method, match.spec_types, match.sparams) @atomic progress.analyzed += 1 reports = JET.get_reports(task_analyzer, result) diff --git a/test/analysis/test_Analyzer.jl b/test/analysis/test_Analyzer.jl index dc1a29365..a982dbb89 100644 --- a/test/analysis/test_Analyzer.jl +++ b/test/analysis/test_Analyzer.jl @@ -34,7 +34,7 @@ function analyze_signature(f; report_target_modules = nothing) world = CC.get_inference_world(analyzer) match = JETLS.signature_analysis_match(analyzer, m.sig, world) match === nothing && error("No method match for signature analysis") - analyzer, result = JET.analyze_method_signature!(analyzer, + result = JET.analyze_method_signature!(analyzer, match.method, match.spec_types, match.sparams) return get_reports(analyzer, result) end From c051b7adb3c96b56d748e615161e025728d1212c Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:03:45 +1000 Subject: [PATCH 10/13] compat: guard 1.14 stdlib and syntax-tree drift (completions, document-symbol, testset) --- src/completions.jl | 13 ++++++++----- src/document-symbol.jl | 2 +- test/HierarchicalTestSet.jl | 6 +++++- test/test_julia114.jl | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 test/test_julia114.jl diff --git a/src/completions.jl b/src/completions.jl index 66b9fbebc..f6d365c18 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -586,11 +586,14 @@ function add_emoji_latex_completions!( newText = val)) end - emojionly || foreach(REPL.REPLCompletions.latex_symbols) do (key, val) - items[key] = create_ci(key, val, false) - end - foreach(REPL.REPLCompletions.emoji_symbols) do (key, val) - items[key] = create_ci(key, val, true) + if emojionly + foreach(REPL.REPLCompletions.emoji_symbols) do (key, val) + items[key] = create_ci(key, val, true) + end + else + foreach(REPL.REPLCompletions.latex_symbols) do (key, val) + items[key] = create_ci(key, val, false) + end end # if we reached here, we have added all emoji and latex completions diff --git a/src/document-symbol.jl b/src/document-symbol.jl index 1669e5bfd..3be630ded 100644 --- a/src/document-symbol.jl +++ b/src/document-symbol.jl @@ -152,7 +152,7 @@ function extract_module_symbol!( parent_context_module::Module ) JS.numchildren(st0) ≥ 3 || return nothing - name_node = st0[2] + name_node = st0[end-1] name = @something get_name_val(name_node) return nothing context_module = parent_context_module if invokelatest(isdefinedglobal, parent_context_module, Symbol(name)) diff --git a/test/HierarchicalTestSet.jl b/test/HierarchicalTestSet.jl index 33c5a606e..0d0f9f290 100644 --- a/test/HierarchicalTestSet.jl +++ b/test/HierarchicalTestSet.jl @@ -54,7 +54,11 @@ function Test.record(ts::HierarchicalTestSet, t::Union{Test.Fail, Test.Error}; end end push!(ts.__hierarchical_testset_inner__.results, t) - (Test.FAIL_FAST[] || ts.__hierarchical_testset_inner__.failfast) && throw(Test.FailFastError()) + failfast = ts.__hierarchical_testset_inner__.failfast + @static if isdefined(Test, :FAIL_FAST) + failfast |= Test.FAIL_FAST[] + end + failfast && throw(Test.FailFastError()) return t end Test.record(ts::HierarchicalTestSet, t) = Test.record(ts.__hierarchical_testset_inner__, t) diff --git a/test/test_julia114.jl b/test/test_julia114.jl new file mode 100644 index 000000000..3bd4df218 --- /dev/null +++ b/test/test_julia114.jl @@ -0,0 +1,36 @@ +module test_julia114 + +include("setup.jl") + +using Test +using JETLS +using JETLS.LSP +using JETLS.URIs2 + +@testset "JETLS boots and handshakes on $(VERSION)" begin + @test withserver(Returns(true)) +end + +@testset "JETLS analyzes and publishes diagnostics on $(VERSION)" begin + scriptcode = """ + include("nonexistent_jetls_114_probe.jl") + """ + withscript(scriptcode) do script_path + uri = filepath2uri(script_path) + withserver() do (; writereadmsg) + (; raw_res) = writereadmsg(make_DidOpenTextDocumentNotification(uri, scriptcode)) + @test raw_res isa PublishDiagnosticsNotification + @test raw_res.params.uri == uri + found = false + for diag in raw_res.params.diagnostics + if diag.source == JETLS.DIAGNOSTIC_SOURCE_SAVE && diag.range.start.line == 0 + found = true + break + end + end + @test found + end + end +end + +end # module test_julia114 From a687aecc3876af6007439d7697f76075d76301c7 Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:03:45 +1000 Subject: [PATCH 11/13] docs: strip satisfied Julia-version compat admonitions from LSP markdown --- src/utils/markdown.jl | 33 ++++++++++++++- test/utils/test_markdown.jl | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/utils/markdown.jl b/src/utils/markdown.jl index d212ccea7..c1617c4b0 100644 --- a/src/utils/markdown.jl +++ b/src/utils/markdown.jl @@ -24,7 +24,38 @@ Render Markdown for LSP display with the following conversions: TODO: Resolve `@ref` targets to actual definitions rather than stripping the link. """ -lsrender(md::Markdown.MD) = strip_ref_links(sprint(lsrender, md)) +lsrender(md::Markdown.MD) = strip_ref_links(sprint(lsrender, strip_satisfied_compat(md))) + +""" + satisfied_julia_compat(title::AbstractString) -> Bool + +`true` when `title` names a Julia version requirement (`Julia 1.5`, +`Julia 1.9.2`, `Julia 1.5 and later`, …) already met by the running server's +`VERSION`. Package-style titles (`ColorTypes 0.10`), unparsable titles, and +unmet Julia requirements return `false`. +""" +function satisfied_julia_compat(title::AbstractString) + tokens = split(title) + length(tokens) ≥ 2 || return false + lowercase(tokens[1]) == "julia" || return false + ver = tryparse(VersionNumber, tokens[2]) + ver === nothing && return false + return VERSION ≥ ver +end + +strip_satisfied_compat(md::Markdown.MD) = + Markdown.MD(filter_satisfied_compat(md.content), md.meta) + +function filter_satisfied_compat(content::AbstractVector) + out = Any[] + for el in content + el isa Markdown.Admonition || (push!(out, el); continue) + lowercase(el.category) == "compat" && satisfied_julia_compat(el.title) && continue + push!(out, Markdown.Admonition( + el.category, el.title, filter_satisfied_compat(el.content))) + end + return out +end lsrender(io::IO, md::Markdown.MarkdownElement) = Markdown.plain(io, md) diff --git a/test/utils/test_markdown.jl b/test/utils/test_markdown.jl index c50e9d7a7..c03f97802 100644 --- a/test/utils/test_markdown.jl +++ b/test/utils/test_markdown.jl @@ -260,4 +260,86 @@ end end end +@testset "Satisfied Julia compat admonitions stripped" begin + unmet = "$(VERSION.major + 1).0" + + # A satisfied `!!! compat "Julia X.Y"` note is pure noise on this server. + let input = """ + Body first. + + !!! compat "Julia 1.0" + This requires Julia 1.0 or later. + """ + result = JETLS.lsrender(Markdown.parse(input)) + @test occursin("Body first.", result) + @test !occursin("Julia 1.0", result) + @test !occursin("requires Julia", result) + end + + # `Julia X.Y and later` suffix form is still recognized and stripped. + let input = """ + !!! compat "Julia 1.5 and later" + Do a thing. + """ + @test JETLS.lsrender(Markdown.parse(input)) == "" + end + + # An UNSATISFIED Julia requirement is vital — keep it. + let input = """ + !!! compat "Julia $unmet" + Needs a newer Julia. + """ + result = JETLS.lsrender(Markdown.parse(input)) + @test occursin("Needs a newer Julia.", result) + @test occursin("⬆️", result) + end + + # Package-style compat titles are not Julia versions — keep them. + let input = """ + !!! compat "ColorTypes 0.10" + Requires ColorTypes 0.10. + """ + result = JETLS.lsrender(Markdown.parse(input)) + @test occursin("Requires ColorTypes 0.10.", result) + end + + # Unparsable / non-version titles are kept. + let input = """ + !!! compat "Julia nightly" + Some note. + """ + result = JETLS.lsrender(Markdown.parse(input)) + @test occursin("Some note.", result) + end + + # Non-compat admonitions are never touched by version filtering. + let input = """ + !!! warning "Julia 1.0" + Kept. + """ + result = JETLS.lsrender(Markdown.parse(input)) + @test occursin("Kept.", result) + end + + # Nested: a satisfied compat note inside another admonition is dropped, + # the outer admonition and its other content survive. + let inner = Markdown.Admonition("compat", "Julia 1.0", + Any[Markdown.parse("Inner satisfied note.").content...]) + keep = Markdown.parse("Keep me.").content[1] + outer = Markdown.Admonition("note", "Outer", Any[keep, inner]) + md = Markdown.MD(Any[outer]) + result = JETLS.lsrender(md) + @test occursin("Keep me.", result) + @test !occursin("Inner satisfied note.", result) + end + + # `satisfied_julia_compat` unit checks. + @test JETLS.satisfied_julia_compat("Julia 1.0") + @test JETLS.satisfied_julia_compat("Julia 1.5 and later") + @test !JETLS.satisfied_julia_compat("Julia $unmet") + @test !JETLS.satisfied_julia_compat("ColorTypes 0.10") + @test !JETLS.satisfied_julia_compat("Julia nightly") + @test !JETLS.satisfied_julia_compat("Julia") +end + end # module test_markdown From 06db36b05f1327a82c2f218a480fb0a0ee1ad59d Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:03:45 +1000 Subject: [PATCH 12/13] hover: order ambiguous-call docs by arity instead of Base insertion order --- src/hover.jl | 25 +++++++++++++++++++------ src/utils/docs.jl | 45 +++++++++++++++++++++++++++++++++++++++++++++ test/test_hover.jl | 30 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/hover.jl b/src/hover.jl index 2edc07121..c80723c07 100644 --- a/src/hover.jl +++ b/src/hover.jl @@ -264,12 +264,25 @@ end # "(generic + the specific method's docs)". function call_doc_sig(ctx::InferredTreeContext, st0_top::SyntaxTreeC, node::SyntaxTreeC) call_node = @something enclosing_call_for_matches(st0_top, node) return nothing - matches = @something get_matches_for_range(ctx, JS.byte_range(call_node)) return nothing - # Require a single matched method — for ambiguous dispatch (union splits, - # multiple matching overloads) fall back to the unnarrowed lookup so we - # don't silently hide applicable docs. - length(matches) == 1 || return nothing - return method_doc_sig(only(matches).method) + arity = call_positional_arity(call_node) + matches = get_matches_for_range(ctx, JS.byte_range(call_node)) + # A unique match narrows to its method signature; ambiguous dispatch hands + # down the call's positional arity so the lookup orders arity-matching docs + # first without hiding any applicable overload. + if matches !== nothing && length(matches) == 1 + s = method_doc_sig(only(matches).method) + s === nothing || return s + end + return arity +end + +function call_positional_arity(call_node::SyntaxTreeC) + n = 0 + for i in 2:JS.numchildren(call_node) + JS.kind(call_node[i]) === JS.K"parameters" && continue + n += 1 + end + return n end # Resolve `node` to a `(parentmod, identifier)` pair and look up its binding- diff --git a/src/utils/docs.jl b/src/utils/docs.jl index d55bcbfb1..b79a0ee03 100644 --- a/src/utils/docs.jl +++ b/src/utils/docs.jl @@ -136,6 +136,46 @@ function narrow_doc_lookup(binding::Base.Docs.Binding, @nospecialize(sig), world return md end +function doc_sig_arity(@nospecialize(msig)) + msig === Union{} && return -1 + body = Base.unwrap_unionall(msig) + (body isa DataType && body <: Tuple) || return -2 + return length(body.parameters) +end + +""" + order_doc_lookup(binding::Base.Docs.Binding, arity::Int, world::UInt) -> Markdown.MD | Nothing + +Ambiguous-dispatch fallback for a call with `arity` positional arguments: keep +*every* stored doc (so no applicable overload is hidden) but surface the docs +whose stored signature arity matches the call — plus interface declarations +(`msig === Union{}`) — ahead of unrelated-arity overloads like the curried +1-arg convenience forms Base attaches to many functions. Ordering only; returns +`nothing` when the binding carries no stored docs so the caller can fall back to +the auto-generated summary. +""" +function order_doc_lookup(binding::Base.Docs.Binding, arity::Int, world::UInt) + matched = Base.Docs.DocStr[] + rest = Base.Docs.DocStr[] + for mod in Base.Docs.modules + dict = @something Base.invoke_in_world(world, Base.Docs.meta, mod; + autoinit=false)::Union{Nothing,IdDict{Any,Any}} continue + haskey(dict, binding) || continue + multidoc = dict[binding]::Base.Docs.MultiDoc + for msig in multidoc.order + a = doc_sig_arity(msig) + push!(a == arity || a == -1 ? matched : rest, multidoc.docs[msig]) + end + end + results = vcat(matched, rest) + isempty(results) && return nothing + md = Base.invoke_in_world(world, Base.Docs.catdoc, map(Base.Docs.parsedoc, results)...) + md isa Markdown.MD || return nothing + md.meta[:results] = results + md.meta[:binding] = binding + return md +end + """ lookup_doc_for_binding(parentmod::Module, name::Symbol, sig, world::UInt) -> doc::Markdown.MD or nothing @@ -159,6 +199,8 @@ function lookup_doc_for_binding( try if sig === nothing return lookup_doc_stripped(binding, world) + elseif sig isa Integer + return @something order_doc_lookup(binding, Int(sig), world) lookup_doc_stripped(binding, world) end return narrow_doc_lookup(binding, sig, world) catch @@ -194,6 +236,9 @@ function lookup_doc_for_value(@nospecialize(v), @nospecialize(sig), world::UInt) end binding = Base.invoke_in_world(world, Base.Docs.aliasof, v, typeof(v)) binding isa Base.Docs.Binding || return nothing + if sig isa Integer + return @something order_doc_lookup(binding, Int(sig), world) lookup_doc_stripped(v, world) + end return narrow_doc_lookup(binding, sig, world) catch return nothing diff --git a/test/test_hover.jl b/test/test_hover.jl index 2d63809b2..f546b6578 100644 --- a/test/test_hover.jl +++ b/test/test_hover.jl @@ -178,6 +178,18 @@ module M_undoc_abstract_dispatch """Doc for `gen(::Vector{T})`.""" gen(a::Vector{T}) where T = a end +module M_curried + # `cur` mirrors the Base pattern (e.g. `isapprox`) of a 1-arg curried + # convenience form documented alongside the primary 2-arg method. A 2-arg + # call with untyped args is ambiguous (both 2-arg methods apply), so the + # lookup can't narrow to one method — the arity-ordering fallback must lead + # with the 2-arg doc rather than Base's insertion order (curried first). + """Curried one-arg doc for `cur`.""" + cur(x) = identity + """Two-arg doc for `cur`.""" + cur(x, y) = x + cur(x, y::Int) = y +end module M_field_hover """Documented field-level struct.""" struct DocStruct @@ -351,6 +363,24 @@ end "Doc for `gen(::Vector{T})`."; context_module = M_undoc_abstract_dispatch) end + + # Ambiguous dispatch: keep every applicable overload's doc but order + # the call-arity-matching (2-arg) doc ahead of the unrelated 1-arg + # curried convenience doc, instead of Base's insertion order. + @testset "ambiguous call orders arity-matching doc first" begin + clean_text, positions = JETLS.get_text_and_positions(""" + function f(a, b) + cur│(a, b) + end + """) + result = get_hover(clean_text, only(positions); context_module = M_curried) + @test result isa Hover + value = result.contents.value + @test occursin("Two-arg doc for `cur`.", value) + @test occursin("Curried one-arg doc for `cur`.", value) + @test occursin( + r"Two-arg doc for `cur`\..*Curried one-arg doc for `cur`\."s, value) + end end # Cursor on an operator-dispatch surface (`xs[i]│`, `[a, b]│`, …) shows From 93c593fba27b5867648dc6eaa8d7ed0d68e35c1c Mon Sep 17 00:00:00 2001 From: koalazub <7111524+koalazub@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:03:45 +1000 Subject: [PATCH 13/13] fix(server): always respond to requests when handlers throw Port handle_request_message_or_respond_error from the deployed 1.14 tree: a throwing request handler now yields an InternalError response instead of leaving the client hanging. Also guard rename's deferred workDoneProgress continuation, which runs outside that guard's scope: a throw (or a client error response to the progress-create request) now sends an error response for the original rename request id. Tested via do_rename overrides on both the direct and deferred paths. --- src/JETLS.jl | 26 ++++++++++++++++- src/rename.jl | 29 +++++++++++++++++-- src/utils/lsp.jl | 7 +++++ test/test_rename.jl | 69 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/JETLS.jl b/src/JETLS.jl index 9f3dd44d7..05a3e5a0f 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -396,7 +396,7 @@ function handler_concurrent_message(server::Server, @nospecialize msg) end elseif isdefined(msg, :id) && (id = msg.id; id isa String || id isa Int) let cancel_flag = get!(()->CancelFlag(false), server.state.currently_handled, id) - Threads.@spawn :default @tryinvokelatest handle_request_message(server, msg, cancel_flag) + Threads.@spawn :default @tryinvokelatest handle_request_message_or_respond_error(server, msg, cancel_flag) end else Threads.@spawn :default @tryinvokelatest handle_notification_message(server, msg) @@ -455,6 +455,30 @@ function handle_response_message( nothing end +""" + handle_request_message_or_respond_error(server::Server, msg, cancel_flag::CancelFlag) + +Run [`handle_request_message`](@ref), and if the handler throws, still send an +`ErrorCodes.InternalError` response for `msg.id`: every LSP request must be +answered or the client blocks on it until its own timeout. Sending the response +also enqueues the `HandledToken` that clears `msg.id` from +`server.state.currently_handled`. +""" +function handle_request_message_or_respond_error(server::Server, @nospecialize(msg), cancel_flag::CancelFlag) + try + handle_request_message(server, msg, cancel_flag) + catch err + @error "handle_request_message failed" id = msg.id + Base.display_error(stderr, err, catch_backtrace()) + send(server, + ResponseMessage(; + id = msg.id, + result = nothing, + error = internal_error(sprint(showerror, err)))) + end + nothing +end + function handle_request_message(server::Server, @nospecialize(msg), cancel_flag::CancelFlag) if is_cancelled(cancel_flag) send(server, diff --git a/src/rename.jl b/src/rename.jl index bfb26ddb0..7e8dd3998 100644 --- a/src/rename.jl +++ b/src/rename.jl @@ -165,15 +165,38 @@ function handle_RenameRequest( return nothing end +""" + handle_rename_progress_response(server::Server, msg, request_caller, progress_cancel_flag) + +Continuation of a deferred `textDocument/rename`: runs after the client answers the +`window/workDoneProgress/create` request, i.e. outside the scope of +[`handle_request_message_or_respond_error`](@ref). A throw here must still produce a +response for the original request id, otherwise the client blocks on the rename request. +""" function handle_rename_progress_response( server::Server, msg::Dict{Symbol,Any}, request_caller::RenameProgressCaller, progress_cancel_flag::CancelFlag) + (; uri, fi, pos, newName, msg_id, token, cancel_flag) = request_caller if handle_response_error(server, msg, "create work done progress") - return + return send(server, + RenameResponse(; + id = msg_id, + result = nothing, + error = request_failed_error("work done progress creation for rename failed"))) end - (; uri, fi, pos, newName, msg_id, token, cancel_flag) = request_caller combined_flag = CombinedCancelFlag(cancel_flag, progress_cancel_flag) - do_rename(server, uri, fi, pos, newName, msg_id, combined_flag; token) + try + do_rename(server, uri, fi, pos, newName, msg_id, combined_flag; token) + catch err + @error "do_rename failed in rename progress continuation" id = msg_id + Base.display_error(stderr, err, catch_backtrace()) + send(server, + RenameResponse(; + id = msg_id, + result = nothing, + error = internal_error(sprint(showerror, err)))) + end + nothing end function do_rename( diff --git a/src/utils/lsp.jl b/src/utils/lsp.jl index 6f62de339..614820868 100644 --- a/src/utils/lsp.jl +++ b/src/utils/lsp.jl @@ -125,6 +125,13 @@ function request_cancelled_error(message::AbstractString="Request was cancelled" data) end +function internal_error(message::AbstractString; data=nothing) + return ResponseError(; + code = ErrorCodes.InternalError, + message, + data) +end + show_message(server::Server, message::AbstractString, type::MessageType.Ty) = send(server, ShowMessageNotification(; params = ShowMessageParams(; type, message))) diff --git a/test/test_rename.jl b/test/test_rename.jl index df181bb3a..de74cf293 100644 --- a/test/test_rename.jl +++ b/test/test_rename.jl @@ -690,4 +690,73 @@ end end end +# The overrides below shadow `JETLS.do_rename` with strictly more specific methods +# (`CancelFlag`/`CombinedCancelFlag` vs the original `AbstractCancelFlag`) that throw, +# simulating a handler crash; `Base.delete_method` restores the original dispatch. +function make_throwing_do_rename(flagtype::Type) + @eval JETLS function do_rename( + server::Server, uri::URI, fi::FileInfo, pos::Position, + newName::String, msg_id::MessageId, cancel_flag::$flagtype; + token::Union{Nothing,ProgressToken} = nothing) + error("deliberate rename failure (test override)") + end + return which(JETLS.do_rename, + Tuple{JETLS.Server, JETLS.URI, JETLS.FileInfo, Position, String, JETLS.MessageId, flagtype}) +end + +@testset "error response when rename handler throws" begin + override = make_throwing_do_rename(JETLS.CancelFlag) + try + withserver() do (; server, writereadmsg, id_counter) + uri = filename2uri(joinpath(@__DIR__, "testfile_$(gensym(:rename_throw)).jl")) + JETLS.cache_file_info!(server, uri, 1, "const target_binding = 1\n") + let id = id_counter[] += 1 + (; raw_res) = writereadmsg(RenameRequest(; + id, + params = RenameParams(; + textDocument = TextDocumentIdentifier(; uri), + position = Position(; line = 0, character = 6), + newName = "renamed_binding"))) + @test raw_res isa ResponseMessage + @test raw_res.id == id + @test isnothing(raw_res.result) + @test raw_res.error isa ResponseError + @test raw_res.error.code == ErrorCodes.InternalError + end + end + finally + Base.delete_method(override) + end +end + +@testset "error response when deferred rename continuation throws" begin + override = make_throwing_do_rename(JETLS.CombinedCancelFlag) + try + capabilities = ClientCapabilities(; + window = WindowClientCapabilities(; workDoneProgress = true)) + withserver(; capabilities) do (; server, writemsg, readmsg, writereadmsg, id_counter) + uri = filename2uri(joinpath(@__DIR__, "testfile_$(gensym(:rename_deferred_throw)).jl")) + JETLS.cache_file_info!(server, uri, 1, "const target_binding = 1\n") + let id = id_counter[] += 1 + (; raw_res) = writereadmsg(RenameRequest(; + id, + params = RenameParams(; + textDocument = TextDocumentIdentifier(; uri), + position = Position(; line = 0, character = 6), + newName = "renamed_binding"))) + @test raw_res isa WorkDoneProgressCreateRequest + writemsg(ResponseMessage(; id = raw_res.id, result = nothing); check = false) + (; raw_msg) = readmsg() + @test raw_msg isa RenameResponse + @test raw_msg.id == id + @test isnothing(raw_msg.result) + @test raw_msg.error isa ResponseError + @test raw_msg.error.code == ErrorCodes.InternalError + end + end + finally + Base.delete_method(override) + end +end + end # test_rename