From 2e2d7550d810ead3e04ef52b52e2558e8e966431 Mon Sep 17 00:00:00 2001 From: MilesCranmer Date: Sat, 17 Jan 2026 19:18:42 +0000 Subject: [PATCH] feat: create plugin system --- jetls-client/package.json | 60 +++++ src/JETLS.jl | 2 + src/analysis/Analyzer.jl | 49 ++++ src/analysis/Interpreter.jl | 19 +- src/analysis/full-analysis.jl | 52 +++- src/config.jl | 25 +- src/diagnostic.jl | 86 +++++- src/plugins.jl | 468 +++++++++++++++++++++++++++++++++ src/types.jl | 45 ++++ src/workspace-configuration.jl | 4 + test/runtests.jl | 1 + test/setup.jl | 15 +- test/test_plugins.jl | 294 +++++++++++++++++++++ 13 files changed, 1079 insertions(+), 41 deletions(-) create mode 100644 src/plugins.jl create mode 100644 test/test_plugins.jl diff --git a/jetls-client/package.json b/jetls-client/package.json index 76eb0280..f4ed3f67 100644 --- a/jetls-client/package.json +++ b/jetls-client/package.json @@ -258,6 +258,66 @@ } } }, + "plugins": { + "type": "array", + "markdownDescription": "JETLS user plugin configuration. Each entry describes a package to load and activate as a JETLS plugin.\n\nFor example: `[{ \"name\": \"BorrowChecker\", \"uuid\": \"...\" }]`.", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "markdownDescription": "Package name (e.g. `'BorrowChecker'`)." + }, + "uuid": { + "type": "string", + "markdownDescription": "Optional package UUID to disambiguate." + }, + "version": { + "type": "string", + "markdownDescription": "Optional version string passed to `Pkg.PackageSpec(version=...)`." + }, + "url": { + "type": "string", + "markdownDescription": "Optional git URL passed to `Pkg.PackageSpec(url=...)`." + }, + "path": { + "type": "string", + "markdownDescription": "Optional local path passed to `Pkg.PackageSpec(path=...)`. Relative paths are resolved against the workspace root." + }, + "subdir": { + "type": "string", + "markdownDescription": "Optional subdirectory within `url`/`path`, passed to `Pkg.PackageSpec(subdir=...)`." + }, + "rev": { + "type": "string", + "markdownDescription": "Optional git revision passed to `Pkg.PackageSpec(rev=...)`." + }, + "entry": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "markdownDescription": "Optional entrypoint(s) within the package module. If set, JETLS will call these to obtain plugins." + }, + "enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Whether this plugin entry is enabled." + } + } + }, + "default": [] + }, "testrunner": { "type": "object", "markdownDescription": "TestRunner integration configuration. See [TestRunner integration](https://aviatesk.github.io/JETLS.jl/release/configuration/#config/testrunner).", diff --git a/src/JETLS.jl b/src/JETLS.jl index f3ad35b1..894a223f 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -82,6 +82,8 @@ include("init-options.jl") include("config.jl") include("workspace-configuration.jl") +include("plugins.jl") + include("diagnostic.jl") include("analysis/Interpreter.jl") diff --git a/src/analysis/Analyzer.jl b/src/analysis/Analyzer.jl index 50577ed2..c35ef96b 100644 --- a/src/analysis/Analyzer.jl +++ b/src/analysis/Analyzer.jl @@ -118,6 +118,45 @@ CC.typeinf_lattice(::LSAnalyzer) = CC.ipo_lattice(::LSAnalyzer) = CC.InferenceLattice(CC.InterMustAliasesLattice(CC.IPOResultLattice.instance)) +# Collect `GeneratorErrorReport`s for LSAnalyzer +# --------------------------------------------- +# +# JET's default `JETAnalyzer` reports generator errors by overloading +# `Core.Compiler.InferenceState(..., ::JETAnalyzer)`. JETLS uses `LSAnalyzer` instead, +# so we replicate the same behavior here so that generator errors are surfaced as +# diagnostics in the language server. + +"""Internal utility to report generator errors as `JET.GeneratorErrorReport`s.""" +function report_generator_error!(analyzer::LSAnalyzer, result::CC.InferenceResult) + mi = result.linfo + m = mi.def::Method + isdefined(m, :generator) || return nothing + CC.may_invoke_generator(mi) || return nothing + + world = CC.get_inference_world(analyzer) + try + @ccall jl_code_for_staged(mi::Any, world::UInt)::Any + catch err + report = add_new_report!(analyzer, result, JET.GeneratorErrorReport(mi, err)) + JET.stash_report!(analyzer, report) + end + return nothing +end + +function CC.InferenceState( + result::CC.InferenceResult, + cache_mode::UInt8, + analyzer::LSAnalyzer, + ) + frame = @invoke CC.InferenceState( + result::CC.InferenceResult, + cache_mode::UInt8, + analyzer::ToplevelAbstractAnalyzer, + ) + frame === nothing && report_generator_error!(analyzer, result) + return frame +end + # AbstractAnalyzer API # ==================== @@ -400,6 +439,16 @@ JETInterface.print_report_message(io::IO, r::BoundsErrorReport) = inference_error_report_stack_impl(r::BoundsErrorReport) = (length(r.vst)-r.vst_offset):-1:1 inference_error_report_severity_impl(::BoundsErrorReport) = DiagnosticSeverity.Warning +# Prefer a user-visible frame for generator errors. +function inference_error_report_stack_impl(r::JET.GeneratorErrorReport) + for i in 1:length(r.vst) + r.vst[i].file === :none && continue + return i:-1:1 + end + return length(r.vst):-1:1 +end +inference_error_report_severity_impl(::JET.GeneratorErrorReport) = DiagnosticSeverity.Error + function report_builtin_error!( analyzer::LSAnalyzer, sv::CC.InferenceState, @nospecialize(f), argtypes::Vector{Any}, @nospecialize(ret), offset::Int diff --git a/src/analysis/Interpreter.jl b/src/analysis/Interpreter.jl index 2a549604..b0d4203f 100644 --- a/src/analysis/Interpreter.jl +++ b/src/analysis/Interpreter.jl @@ -5,7 +5,8 @@ export LSInterpreter using JuliaSyntax: JuliaSyntax as JS using JET: CC, JET, JuliaInterpreter using ..JETLS: - AnalysisRequest, AnalysisResult, SavedFileInfo, Server, JETLS, JETLS_DEV_MODE, + AbstractJETLSPlugin, AnalysisRequest, AnalysisResult, SavedFileInfo, Server, JETLS, + JETLS_DEV_MODE, active_plugins, is_cancelled, send_progress, yield_to_endpoint using ..JETLS.URIs2 using ..JETLS.LSP @@ -21,6 +22,7 @@ Base.getindex(counter::Counter) = counter.count struct LSInterpreter{S<:Server} <: JET.ConcreteInterpreter server::S request::AnalysisRequest + plugins::Vector{AbstractJETLSPlugin} analyzer::LSAnalyzer counter::Counter activation_done::Union{Nothing,Base.Event} @@ -28,20 +30,22 @@ struct LSInterpreter{S<:Server} <: JET.ConcreteInterpreter current_node::Base.RefValue{JS.SyntaxNode} state::JET.InterpretationState function LSInterpreter( - server::S, request::AnalysisRequest, analyzer::LSAnalyzer, counter::Counter, + server::S, request::AnalysisRequest, plugins::Vector{AbstractJETLSPlugin}, + analyzer::LSAnalyzer, counter::Counter, activation_done::Union{Nothing,Base.Event} ) where S<:Server - return new{S}(server, request, analyzer, counter, activation_done, + return new{S}(server, request, plugins, analyzer, counter, activation_done, JETLS.ToplevelWarningReport[], Base.RefValue{JS.SyntaxNode}()) end function LSInterpreter( - server::S, request::AnalysisRequest, analyzer::LSAnalyzer, counter::Counter, + server::S, request::AnalysisRequest, plugins::Vector{AbstractJETLSPlugin}, + analyzer::LSAnalyzer, counter::Counter, activation_done::Union{Nothing,Base.Event}, warning_reports::Vector{JETLS.ToplevelWarningReport}, current_node::Base.RefValue{JS.SyntaxNode}, state::JET.InterpretationState, ) where S<:Server - return new{S}(server, request, analyzer, counter, activation_done, + return new{S}(server, request, plugins, analyzer, counter, activation_done, warning_reports, current_node, state) end end @@ -51,14 +55,15 @@ function LSInterpreter( server::Server, request::AnalysisRequest; activation_done::Union{Nothing,Base.Event} = nothing ) - return LSInterpreter(server, request, LSAnalyzer(request.entry), Counter(), activation_done) + plugins = active_plugins(server, request.entry) + return LSInterpreter(server, request, plugins, LSAnalyzer(request.entry), Counter(), activation_done) end # `JET.ConcreteInterpreter` interface JET.InterpretationState(interp::LSInterpreter) = interp.state function JET.ConcreteInterpreter(interp::LSInterpreter, state::JET.InterpretationState) return LSInterpreter( - interp.server, interp.request, interp.analyzer, interp.counter, + interp.server, interp.request, interp.plugins, interp.analyzer, interp.counter, interp.activation_done, interp.warning_reports, interp.current_node, state) end JET.ToplevelAbstractAnalyzer(interp::LSInterpreter) = interp.analyzer diff --git a/src/analysis/full-analysis.jl b/src/analysis/full-analysis.jl index a05afe98..772200c6 100644 --- a/src/analysis/full-analysis.jl +++ b/src/analysis/full-analysis.jl @@ -562,15 +562,21 @@ function analyze_parsed_if_exist( activation_done::Union{Nothing,Base.Event} = nothing ) uri = entryuri(request.entry) - jetconfigs = getjetconfigs(request.entry) + # Construct the interpreter first so we can apply user plugins to the analysis config. + interp = LSInterpreter(server, request; activation_done) + + jetconfigs = copy(getjetconfigs(request.entry)) + for plugin in interp.plugins + # Plugins may be loaded dynamically after the server has started and tasks have + # been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + Base.invokelatest(plugin_modify_jetconfigs!, plugin, request.entry, jetconfigs) + end fi = get_saved_file_info(server.state, uri) if !isnothing(fi) filename = uri2filename(uri) - interp = LSInterpreter(server, request; activation_done) return interp, JET.analyze_and_report_expr!(interp, fi.syntax_node, filename, args...; jetconfigs...) else filepath = @something uri2filepath(uri) error(lazy"Unsupported URI: $uri") - interp = LSInterpreter(server, request; activation_done) return interp, JET.analyze_and_report_file!(interp, filepath, args...; jetconfigs...) end end @@ -591,7 +597,7 @@ function new_analysis_result(interp::LSInterpreter, request::AnalysisRequest, re uri2diagnostics = URI2Diagnostics(uri => Diagnostic[] for uri in keys(analyzed_file_infos)) postprocessor = JET.PostProcessor(result.res.actual2virtual) toplevel_warning_reports_to_diagnostics!(uri2diagnostics, interp.warning_reports, interp.server, postprocessor) - jet_result_to_diagnostics!(uri2diagnostics, result, postprocessor) + jet_result_to_diagnostics!(uri2diagnostics, result, postprocessor, interp.plugins) (; entry, prev_analysis_result) = request if !(isempty(result.res.toplevel_error_reports) || isnothing(prev_analysis_result)) @@ -853,23 +859,47 @@ function analyze_package_with_revise( uri2diagnostics = URI2Diagnostics(uri => Diagnostic[] for uri in keys(analyzed_file_infos)) postprocessor = JET.PostProcessor() + plugins = active_plugins(server, request.entry) toplevel_warning_reports_to_diagnostics!(uri2diagnostics, warning_reports, server, postprocessor) - inference_reports = collect_displayable_reports(progress.reports, keys(uri2diagnostics)) + inference_reports = collect_displayable_reports(progress.reports, keys(uri2diagnostics), plugins) unique!(JET.aggregation_policy(analyzer), inference_reports) - jet_inference_error_reports_to_diagnostics!(uri2diagnostics, inference_reports, postprocessor) + jet_inference_error_reports_to_diagnostics!(uri2diagnostics, inference_reports, postprocessor, plugins) actual2virtual = pkgmod => pkgmod # No virtual module for Revise-based analysis return AnalysisResult(request.entry, uri2diagnostics, update_analyzer_world(analyzer), analyzed_file_infos, actual2virtual) end function collect_displayable_reports( - reports::Vector{JET.InferenceErrorReport}, target_uris + reports::Vector{JET.InferenceErrorReport}, + target_uris, + ) + return collect_displayable_reports(reports, target_uris, AbstractJETLSPlugin[]) +end + +function collect_displayable_reports( + reports::Vector{JET.InferenceErrorReport}, + target_uris, + plugins::Vector{AbstractJETLSPlugin}, ) filter(reports) do report::JET.InferenceErrorReport - stk = inference_error_report_stack(report) - topframe = report.vst[first(stk)] - uri = @something jet_frame_to_uri(topframe) return false - return uri in target_uris + # 1) Any frame in the report's stack belongs to the current analysis unit. + for i in inference_error_report_stack(report) + frame = report.vst[i] + uri = jet_frame_to_uri(frame) + uri === nothing && continue + uri in target_uris && return true + end + + # 2) Plugins can report additional URIs (e.g. errors that carry their own locations). + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks + # have been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + for uri in Base.invokelatest(plugin_additional_report_uris, plugin, report) + uri in target_uris && return true + end + end + + return false end end diff --git a/src/config.jl b/src/config.jl index d2bcc91d..8098291a 100644 --- a/src/config.jl +++ b/src/config.jl @@ -27,19 +27,23 @@ function merge_and_track( K = fieldtype(T, key) old_by_key = Dict{K,T}(getfield(item, key) => item for item in old_config) new_by_key = Dict{K,T}(getfield(item, key) => item for item in new_config) + + # Preserve the ordering of `new_config` (config overlay should control order). result = T[] - for (k, old_item) in old_by_key - if haskey(new_by_key, k) - push!(result, merge_and_track(on_difference, old_item, new_by_key[k], path)) + for new_item in new_config + k = getfield(new_item, key) + if haskey(old_by_key, k) + push!(result, merge_and_track(on_difference, old_by_key[k], new_item, path)) else - push!(result, merge_and_track(on_difference, old_item, nothing, path)) - end - end - for (k, new_item) in new_by_key - if !haskey(old_by_key, k) push!(result, merge_and_track(on_difference, nothing, new_item, path)) end end + # Keep entries that exist only in `old_config` (append in their original order). + for old_item in old_config + k = getfield(old_item, key) + haskey(new_by_key, k) && continue + push!(result, merge_and_track(on_difference, old_item, nothing, path)) + end return result end @@ -248,8 +252,9 @@ end mutable struct ConfigChangeTracker const changed_settings::Vector{ConfigChange} diagnostic_setting_changed::Bool + plugins_setting_changed::Bool end -ConfigChangeTracker() = ConfigChangeTracker(ConfigChange[], false) +ConfigChangeTracker() = ConfigChangeTracker(ConfigChange[], false, false) function (tracker::ConfigChangeTracker)(old_val, new_val, path::Tuple{Vararg{Symbol}}) @nospecialize old_val new_val @@ -258,6 +263,8 @@ function (tracker::ConfigChangeTracker)(old_val, new_val, path::Tuple{Vararg{Sym push!(tracker.changed_settings, ConfigChange(path_str, old_val, new_val)) if !isempty(path) && first(path) === :diagnostic tracker.diagnostic_setting_changed = true + elseif !isempty(path) && first(path) === :plugins + tracker.plugins_setting_changed = true end end end diff --git a/src/diagnostic.jl b/src/diagnostic.jl index b7998fb5..e66c65d5 100644 --- a/src/diagnostic.jl +++ b/src/diagnostic.jl @@ -322,7 +322,12 @@ end # JET diagnostics # =============== -function jet_result_to_diagnostics!(uri2diagnostics::URI2Diagnostics, result::JET.JETToplevelResult, postprocessor::JET.PostProcessor) +function jet_result_to_diagnostics!( + uri2diagnostics::URI2Diagnostics, + result::JET.JETToplevelResult, + postprocessor::JET.PostProcessor, + plugins::Vector{AbstractJETLSPlugin}, + ) for report in result.res.toplevel_error_reports if report isa JET.LoweringErrorReport || report isa JET.MacroExpansionErrorReport # the equivalent report should have been reported by `lowering_diagnostics!` @@ -339,8 +344,8 @@ function jet_result_to_diagnostics!(uri2diagnostics::URI2Diagnostics, result::JE end push!(uri2diagnostics[uri], diagnostic) end - displayable_reports = collect_displayable_reports(result.res.inference_error_reports, keys(uri2diagnostics)) - jet_inference_error_reports_to_diagnostics!(uri2diagnostics, displayable_reports, postprocessor) + displayable_reports = collect_displayable_reports(result.res.inference_error_reports, keys(uri2diagnostics), plugins) + jet_inference_error_reports_to_diagnostics!(uri2diagnostics, displayable_reports, postprocessor, plugins) return uri2diagnostics end @@ -371,22 +376,59 @@ end # -------------------- function jet_inference_error_reports_to_diagnostics!( - uri2diagnostics::URI2Diagnostics, reports::Vector{JET.InferenceErrorReport}, - postprocessor::JET.PostProcessor + uri2diagnostics::URI2Diagnostics, + reports::Vector{JET.InferenceErrorReport}, + postprocessor::JET.PostProcessor, + plugins::Vector{AbstractJETLSPlugin}, ) for report in reports - diagnostic = jet_inference_error_report_to_diagnostic(report, postprocessor) - topframeidx = first(inference_error_report_stack(report)) + # Give plugins a chance to expand the report into (possibly multiple) diagnostics. + handled = false + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks + # have been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + handled = Base.invokelatest( + plugin_expand_inference_error_report!, + plugin, + uri2diagnostics, + report, + postprocessor, + ) + handled && break + end + handled && continue + + diagnostic = jet_inference_error_report_to_diagnostic(report, postprocessor, plugins) + rstack = inference_error_report_stack(report) + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks + # have been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + stack = Base.invokelatest(plugin_inference_error_report_stack, plugin, report) + stack === nothing || (rstack = stack; break) + end + topframeidx = first(rstack) topframe = report.vst[topframeidx] topframe.file === :none && continue # TODO Figure out why this is necessary uri = jet_frame_to_uri(topframe) - push!(uri2diagnostics[uri], diagnostic) # collect_displayable_reports asserts that this `uri` key exists for `uri2diagnostics` + push!(get!(uri2diagnostics, uri) do + Diagnostic[] + end, diagnostic) end return uri2diagnostics end -function jet_inference_error_report_to_diagnostic(@nospecialize(report::JET.InferenceErrorReport), postprocessor::JET.PostProcessor) +function jet_inference_error_report_to_diagnostic( + @nospecialize(report::JET.InferenceErrorReport), + postprocessor::JET.PostProcessor, + plugins::Vector{AbstractJETLSPlugin}, + ) rstack = inference_error_report_stack(report) + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks have + # been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + stack = Base.invokelatest(plugin_inference_error_report_stack, plugin, report) + stack === nothing || (rstack = stack; break) + end topframe = report.vst[first(rstack)] message = JET.with_bufferring(:limit=>true) do io JET.print_report_message(io, report) @@ -398,10 +440,17 @@ function jet_inference_error_report_to_diagnostic(@nospecialize(report::JET.Infe local message = postprocessor(sprint(JET.print_frame_sig, frame, JET.PrintConfig())) push!(relatedInformation, DiagnosticRelatedInformation(; location, message)) end - code = inference_error_report_code(report) + code = inference_error_report_code(report, plugins) + severity = inference_error_report_severity(report) + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks have + # been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + sev = Base.invokelatest(plugin_inference_error_report_severity, plugin, report) + sev === nothing || (severity = sev; break) + end return Diagnostic(; range = jet_frame_to_range(topframe), - severity = inference_error_report_severity(report), + severity, message, source = DIAGNOSTIC_SOURCE, code, @@ -409,7 +458,16 @@ function jet_inference_error_report_to_diagnostic(@nospecialize(report::JET.Infe relatedInformation) end -function inference_error_report_code(@nospecialize report::JET.InferenceErrorReport) +function inference_error_report_code( + @nospecialize(report::JET.InferenceErrorReport), + plugins::Vector{AbstractJETLSPlugin}, + ) + for plugin in plugins + # Plugins may be loaded dynamically after the server has started and tasks have + # been spawned; use `invokelatest` to avoid world-age issues (Julia 1.12+). + code = Base.invokelatest(plugin_inference_error_report_code, plugin, report) + code === nothing || return code + end if report isa UndefVarErrorReport if report.var isa GlobalRef return INFERENCE_UNDEF_GLOBAL_VAR_CODE @@ -422,8 +480,10 @@ function inference_error_report_code(@nospecialize report::JET.InferenceErrorRep return INFERENCE_FIELD_ERROR_CODE elseif report isa BoundsErrorReport return INFERENCE_BOUNDS_ERROR_CODE + elseif report isa JET.GeneratorErrorReport + return INFERENCE_GENERATOR_ERROR_CODE end - error(lazy"Diagnostic code is not defined for this report: $report") + return INFERENCE_ERROR_CODE end # toplevel warning diagnostic diff --git a/src/plugins.jl b/src/plugins.jl new file mode 100644 index 00000000..08567098 --- /dev/null +++ b/src/plugins.jl @@ -0,0 +1,468 @@ +""" +JETLS plugin system +=================== + +JETLS is built on JET's analysis framework and already uses JET's internal plugin system, +but historically it has not provided an officially supported way for *users* to extend the +analysis/diagnostics pipeline. + +This file defines a small, server-side plugin interface that can be enabled via the +existing LSP configuration mechanism (e.g. VSCode's `settings.json`). + +The intended workflow is: +1. A user configures `jetls-client.settings.plugins` with package names (and optionally + UUIDs). +2. JETLS loads those packages in the analysis environment. +3. Loaded packages register plugin instances via [`register_plugin!`](@ref). +4. JETLS calls plugin hooks to customize analysis configuration and diagnostics. + +The interface is intentionally narrow and focused on LSP diagnostics. +""" + +# Plugin interface +# ---------------- + +"""Base type for JETLS plugins.""" +abstract type AbstractJETLSPlugin end + +"""Unwrap nested `LoadError`s to reach the underlying exception.""" +function unwrap_loaderror(@nospecialize(err)) + while err isa LoadError + err = err.error + end + return err +end + +"""Register an additional diagnostic code so users can configure it via patterns.""" +register_diagnostic_code!(code::AbstractString) = push!(ALL_DIAGNOSTIC_CODES, String(code)) + +const _PLUGIN_REGISTRY_LOCK = ReentrantLock() +const _PLUGIN_REGISTRY = Dict{Base.PkgId, Vector{AbstractJETLSPlugin}}() + +function _plugin_owner_pkgid(plugin::AbstractJETLSPlugin, owner) + if owner isa Base.PkgId + return owner + elseif owner isa Module + try + return Base.PkgId(owner) + catch + return Base.PkgId(nothing, String(nameof(owner))) + end + elseif owner === nothing + mod = parentmodule(typeof(plugin)) + try + return Base.PkgId(mod) + catch + return Base.PkgId(nothing, String(nameof(mod))) + end + else + throw(ArgumentError("Invalid `owner` argument for register_plugin!: $(typeof(owner))")) + end +end + +""" + register_plugin!(plugin::AbstractJETLSPlugin; owner=nothing) -> plugin + +Registers a plugin instance. + +`owner` is used to associate the plugin instance with a package, so that enabling/disabling +plugins via the LSP configuration can be implemented as a pure filter step. + +- If `owner` is omitted, JETLS will attempt to infer it from `parentmodule(typeof(plugin))`. +- If `owner` is a `Module`, it will be converted to a `Base.PkgId`. +- If `owner` is a `Base.PkgId`, it will be used directly. + +Downstream packages are encouraged to register plugins from a package extension +(`ext/*.jl`) so that registration only happens when both the plugin package and JETLS are +loaded. +""" +function register_plugin!(plugin::AbstractJETLSPlugin; owner=nothing) + pkgid = _plugin_owner_pkgid(plugin, owner) + @lock _PLUGIN_REGISTRY_LOCK begin + plugins = get!(_PLUGIN_REGISTRY, pkgid) do + AbstractJETLSPlugin[] + end + any(p -> p === plugin, plugins) || push!(plugins, plugin) + end + return plugin +end + +function _registered_plugins_for_pkgid(pkgid::Base.PkgId) + @lock _PLUGIN_REGISTRY_LOCK begin + return get(_PLUGIN_REGISTRY, pkgid, AbstractJETLSPlugin[]) + end +end + +# Plugin hooks +# ------------ + +"""Allow a plugin to tweak JET configurations before running analysis.""" +plugin_modify_jetconfigs!(::AbstractJETLSPlugin, ::AnalysisEntry, ::Dict{Symbol,Any}) = nothing + +"""Allow a plugin to treat a report as "displayable" for additional URIs.""" +plugin_additional_report_uris(::AbstractJETLSPlugin, ::JET.InferenceErrorReport) = URI[] + +"""Override report stack used for diagnostic locations/related information.""" +plugin_inference_error_report_stack(::AbstractJETLSPlugin, ::JET.InferenceErrorReport) = nothing + +"""Override severity of an inference report.""" +plugin_inference_error_report_severity(::AbstractJETLSPlugin, ::JET.InferenceErrorReport) = nothing + +"""Override diagnostic code associated with an inference report.""" +plugin_inference_error_report_code(::AbstractJETLSPlugin, ::JET.InferenceErrorReport) = nothing + +""" + plugin_expand_inference_error_report!(plugin, uri2diagnostics, report, postprocessor) -> Bool + +If this hook returns `true`, the plugin has fully handled `report` and the default +JETLS expansion should be skipped. +""" +plugin_expand_inference_error_report!(::AbstractJETLSPlugin, ::URI2Diagnostics, ::JET.InferenceErrorReport, ::JET.PostProcessor) = false + + +# Plugin configuration parsing +# ---------------------------- + +struct PluginConfigError <: Exception + msg::AbstractString +end +Base.showerror(io::IO, e::PluginConfigError) = print(io, "PluginConfigError: ", e.msg) + +function parse_plugin_spec(x::AbstractDict{String}) + for key in keys(x) + if key ∉ ("name", "uuid", "version", "url", "path", "subdir", "rev", "mode", "level", "entry", "entries", "enabled") + throw(PluginConfigError( + lazy"Unknown field \"$key\" in plugin spec. Valid fields are: name, uuid, version, url, path, subdir, rev, entry, enabled")) + end + end + + name = get(x, "name", nothing) + name isa String || throw(PluginConfigError(lazy"Missing or invalid `name` in plugin spec")) + isempty(name) && throw(PluginConfigError("Plugin `name` must not be empty")) + + uuid = get(x, "uuid", nothing) + uuid_parsed = if uuid === nothing + nothing + elseif uuid isa String + isempty(uuid) ? nothing : try + Base.UUID(uuid) + catch + throw(PluginConfigError(lazy"Invalid `uuid` value for plugin \"$name\": $uuid")) + end + else + throw(PluginConfigError(lazy"Invalid `uuid` value for plugin \"$name\". Must be a UUID string")) + end + + version = get(x, "version", nothing) + version_parsed = if version === nothing + nothing + elseif version isa String + isempty(version) ? nothing : try + VersionNumber(version) + catch + throw(PluginConfigError(lazy"Invalid `version` value for plugin \"$name\": $version")) + end + else + throw(PluginConfigError(lazy"Invalid `version` value for plugin \"$name\". Must be a version string")) + end + + url = get(x, "url", nothing) + url_parsed = if url === nothing + nothing + elseif url isa String + isempty(url) ? nothing : url + else + throw(PluginConfigError(lazy"Invalid `url` value for plugin \"$name\". Must be a string")) + end + + path = get(x, "path", nothing) + path_parsed = if path === nothing + nothing + elseif path isa String + isempty(path) ? nothing : path + else + throw(PluginConfigError(lazy"Invalid `path` value for plugin \"$name\". Must be a string")) + end + + subdir = get(x, "subdir", nothing) + subdir_parsed = if subdir === nothing + nothing + elseif subdir isa String + isempty(subdir) ? nothing : subdir + else + throw(PluginConfigError(lazy"Invalid `subdir` value for plugin \"$name\". Must be a string")) + end + + rev = get(x, "rev", nothing) + rev_parsed = if rev === nothing + nothing + elseif rev isa String + isempty(rev) ? nothing : rev + else + throw(PluginConfigError(lazy"Invalid `rev` value for plugin \"$name\". Must be a string")) + end + + entry_value = get(x, "entry", get(x, "entries", nothing)) + entry = if entry_value === nothing + String[] + elseif entry_value isa String + String[entry_value] + elseif entry_value isa AbstractVector + entries = String[] + for v in entry_value + v isa String || throw(PluginConfigError(lazy"Invalid `entry` value for plugin \"$name\". Expected strings")) + push!(entries, v) + end + entries + else + throw(PluginConfigError(lazy"Invalid `entry` value for plugin \"$name\". Must be a string or list of strings")) + end + + enabled = get(x, "enabled", true) + enabled isa Bool || throw(PluginConfigError(lazy"Invalid `enabled` value for plugin \"$name\". Must be a boolean")) + + return PluginSpec( + name, + uuid_parsed, + version_parsed, + url_parsed, + path_parsed, + subdir_parsed, + rev_parsed, + entry, + enabled, + ) +end + + +# Plugin loading / activation +# --------------------------- + +const _PLUGIN_LOAD_ERROR_ONCE_LOCK = ReentrantLock() +const _PLUGIN_LOAD_ERROR_ONCE = Set{Tuple{UInt,String}}() # (server_state_id, plugin_name) +const _PLUGIN_PKG_OP_LOCK = ReentrantLock() + +function _maybe_show_plugin_load_error!(server::Server, plugin_name::String, msg::String) + sid = objectid(server.state) + key = (sid, plugin_name) + show = false + @lock _PLUGIN_LOAD_ERROR_ONCE_LOCK begin + if key ∉ _PLUGIN_LOAD_ERROR_ONCE + push!(_PLUGIN_LOAD_ERROR_ONCE, key) + show = true + end + end + show || return nothing + try + show_error_message(server, msg) + catch + # ignore notification errors in early init/tests + end + return nothing +end + +function _server_root_path(server::Server) + if isdefined(server.state, :root_path) + root_path = server.state.root_path + isempty(root_path) && return nothing + return root_path + end + return nothing +end + +function _resolve_plugin_path(server::Server, path::String) + isabspath(path) && return path + root_path = _server_root_path(server) + root_path === nothing && return abspath(path) + return abspath(joinpath(root_path, path)) +end + +function _plugin_packagespec(server::Server, spec::PluginSpec) + kwargs = Pair{Symbol,Any}[] + push!(kwargs, :name => spec.name) + spec.uuid === nothing || push!(kwargs, :uuid => spec.uuid) + spec.version === nothing || push!(kwargs, :version => spec.version) + spec.url === nothing || push!(kwargs, :url => spec.url) + if spec.path !== nothing + push!(kwargs, :path => _resolve_plugin_path(server, spec.path)) + end + spec.subdir === nothing || push!(kwargs, :subdir => spec.subdir) + spec.rev === nothing || push!(kwargs, :rev => spec.rev) + return Pkg.PackageSpec(; kwargs...) +end + +function _plugin_has_pkg_spec(spec::PluginSpec) + return spec.uuid !== nothing || + spec.version !== nothing || + spec.url !== nothing || + spec.path !== nothing || + spec.subdir !== nothing || + spec.rev !== nothing +end + +"""Resolve a `PluginSpec` to a concrete `Base.PkgId`, if possible.""" +function _resolve_plugin_pkgid(spec::PluginSpec) + if spec.uuid !== nothing + return Base.PkgId(spec.uuid, spec.name) + end + pkgenv = @lock Base.require_lock Base.identify_package_env(spec.name) + pkgenv === nothing && return nothing + pkgid, _ = pkgenv + return pkgid +end + +function _require_plugin_package(server::Server, spec::PluginSpec) + @lock _PLUGIN_PKG_OP_LOCK begin + if _plugin_has_pkg_spec(spec) && ( + spec.path !== nothing || + spec.url !== nothing || + spec.version !== nothing || + spec.subdir !== nothing || + spec.rev !== nothing + ) + pspec = _plugin_packagespec(server, spec) + try + if spec.path !== nothing + Pkg.develop(pspec) + else + Pkg.add(pspec) + end + catch err + msg = "Failed to install plugin package $(spec.name): $(sprint(Base.showerror, err))" + _maybe_show_plugin_load_error!(server, spec.name, msg) + @warn msg exception=(err, catch_backtrace()) + return nothing + end + end + + pkgid = _resolve_plugin_pkgid(spec) + pkgid === nothing && return nothing + try + Base.require(pkgid) + catch err + # If the user provided a package spec but the package isn't installed yet, try once. + if _plugin_has_pkg_spec(spec) + pspec = _plugin_packagespec(server, spec) + try + if spec.path !== nothing + Pkg.develop(pspec) + else + Pkg.add(pspec) + end + Base.require(pkgid) + return pkgid + catch err2 + msg = "Failed to load plugin package $(spec.name): $(sprint(Base.showerror, err2))" + _maybe_show_plugin_load_error!(server, spec.name, msg) + @warn msg exception=(err2, catch_backtrace()) + return nothing + end + end + + msg = "Failed to load plugin package $(spec.name): $(sprint(Base.showerror, err))" + _maybe_show_plugin_load_error!(server, spec.name, msg) + @warn msg exception=(err, catch_backtrace()) + return nothing + end + return pkgid + end +end + +function _plugins_from_entrypoints(mod::Module, spec::PluginSpec) + isempty(spec.entry) && return AbstractJETLSPlugin[] + plugins = AbstractJETLSPlugin[] + for entry in spec.entry + sym = Symbol(entry) + isdefined(mod, sym) || begin + @warn "JETLS plugin entry not found" plugin=spec.name entry + continue + end + obj = getfield(mod, sym) + if obj isa AbstractJETLSPlugin + push!(plugins, obj) + elseif obj isa Function + val = obj() + if val isa AbstractJETLSPlugin + push!(plugins, val) + elseif val isa AbstractVector + for p in val + p isa AbstractJETLSPlugin || continue + push!(plugins, p) + end + elseif val === nothing + # allow entry points that register plugins via `register_plugin!` + else + @warn "JETLS plugin entry returned unexpected value" plugin=spec.name entry returned=typeof(val) + end + else + @warn "JETLS plugin entry has unexpected type" plugin=spec.name entry ty=typeof(obj) + end + end + return plugins +end + +const _ACTIVE_PLUGIN_CACHE_LOCK = ReentrantLock() +const _ACTIVE_PLUGIN_CACHE = Dict{UInt, Dict{String, Tuple{UInt, Vector{AbstractJETLSPlugin}}}}() + +function _plugin_env_key(@nospecialize(entry::AnalysisEntry)) + if hasproperty(entry, :env_path) + env_path = getproperty(entry, :env_path) + env_path isa String && return env_path + end + return "__noenv__" +end + +"""Return currently active plugins for the given analysis entry.""" +function active_plugins(server::Server, entry::AnalysisEntry) + specs = get_config(server.state.config_manager, :plugins) + isempty(specs) && return AbstractJETLSPlugin[] + specs = filter(spec -> spec.enabled, specs) + isempty(specs) && return AbstractJETLSPlugin[] + + env_key = _plugin_env_key(entry) + sig = hash(specs, UInt(0)) + sid = objectid(server.state) + + cached = nothing + @lock _ACTIVE_PLUGIN_CACHE_LOCK begin + env_cache = get!(_ACTIVE_PLUGIN_CACHE, sid) do + Dict{String, Tuple{UInt, Vector{AbstractJETLSPlugin}}}() + end + cached = get(env_cache, env_key, nothing) + if cached !== nothing && first(cached) == sig + return last(cached) + end + end + + # Cache miss (or changed settings) – build a fresh active plugin list. + plugins = AbstractJETLSPlugin[] + for spec in specs + pkgid = _require_plugin_package(server, spec) + pkgid === nothing && continue + mod = get(Base.loaded_modules, pkgid, nothing) + mod === nothing && continue + + entry_plugins = _plugins_from_entrypoints(mod, spec) + if !isempty(entry_plugins) + append!(plugins, entry_plugins) + else + # Default behavior: use plugins registered by the package. + append!(plugins, _registered_plugins_for_pkgid(pkgid)) + end + end + + # Deduplicate by identity (plugins are usually singleton objects). + unique_plugins = AbstractJETLSPlugin[] + for p in plugins + any(q -> q === p, unique_plugins) || push!(unique_plugins, p) + end + + @lock _ACTIVE_PLUGIN_CACHE_LOCK begin + env_cache = get!(_ACTIVE_PLUGIN_CACHE, sid) do + Dict{String, Tuple{UInt, Vector{AbstractJETLSPlugin}}}() + end + env_cache[env_key] = (sig, unique_plugins) + end + + return unique_plugins +end diff --git a/src/types.jl b/src/types.jl index cf28238d..67416702 100644 --- a/src/types.jl +++ b/src/types.jl @@ -383,8 +383,10 @@ const TOPLEVEL_ABSTRACT_FIELD_CODE = "toplevel/abstract-field" const INFERENCE_UNDEF_GLOBAL_VAR_CODE = "inference/undef-global-var" const INFERENCE_UNDEF_LOCAL_VAR_CODE = "inference/undef-local-var" const INFERENCE_UNDEF_STATIC_PARAM_CODE = "inference/undef-static-param" # currently not reported +const INFERENCE_ERROR_CODE = "inference/error" const INFERENCE_FIELD_ERROR_CODE = "inference/field-error" const INFERENCE_BOUNDS_ERROR_CODE = "inference/bounds-error" +const INFERENCE_GENERATOR_ERROR_CODE = "inference/generator-error" const TESTRUNNER_TEST_FAILURE_CODE = "testrunner/test-failure" const ALL_DIAGNOSTIC_CODES = Set{String}(String[ @@ -401,8 +403,10 @@ const ALL_DIAGNOSTIC_CODES = Set{String}(String[ INFERENCE_UNDEF_GLOBAL_VAR_CODE, INFERENCE_UNDEF_LOCAL_VAR_CODE, INFERENCE_UNDEF_STATIC_PARAM_CODE, + INFERENCE_ERROR_CODE, INFERENCE_FIELD_ERROR_CODE, INFERENCE_BOUNDS_ERROR_CODE, + INFERENCE_GENERATOR_ERROR_CODE, TESTRUNNER_TEST_FAILURE_CODE, ]) @@ -470,12 +474,52 @@ end method_signature::Maybe{MethodSignatureConfig} end +""" + PluginSpec <: ConfigSection + +Configuration entry for a user plugin. + +This is expected to be configured by the LSP client (e.g. VSCode's `settings.json`) under +`jetls-client.settings.plugins`. + +Fields: +- `name`: Package name to load. +- `uuid`: Optional UUID string. Recommended when the package name is ambiguous. +- `version`: Optional version constraint. +- `url`: Optional git URL to `Pkg.add`. +- `path`: Optional local path to `Pkg.develop`. +- `subdir`: Optional subdirectory within `url`/`path`. +- `rev`: Optional git revision. +- `entry`: Optional list of entry points in the package module. + If empty, JETLS will use plugins registered via `JETLS.register_plugin!`. +- `enabled`: Allows temporarily disabling a plugin without removing the entry. +""" +struct PluginSpec <: ConfigSection + name::String + uuid::Union{Nothing,Base.UUID} + version::Union{Nothing,VersionNumber} + url::Union{Nothing,String} + path::Union{Nothing,String} + subdir::Union{Nothing,String} + rev::Union{Nothing,String} + entry::Vector{String} + enabled::Bool +end +@define_eq_overloads PluginSpec + +# Overload to inject custom validations for parsing `PluginSpec` from config dictionaries. +Base.convert(::Type{PluginSpec}, x::AbstractDict{String}) = parse_plugin_spec(x) + +# Merge `plugins` settings by package name. +merge_key(::Type{PluginSpec}) = :name + @option struct JETLSConfig <: ConfigSection diagnostic::Maybe{DiagnosticConfig} full_analysis::Maybe{FullAnalysisConfig} testrunner::Maybe{TestRunnerConfig} formatter::Maybe{FormatterConfig} completion::Maybe{CompletionConfig} + plugins::Maybe{Vector{PluginSpec}} # This initialization options are read once at the server initialization and held in # `server.state.init_options`, so it might seem strange to hold them here also, # but they need to be set here for cases where initialization options are set in @@ -489,6 +533,7 @@ const DEFAULT_CONFIG = JETLSConfig(; testrunner = TestRunnerConfig(@static Sys.iswindows() ? "testrunner.bat" : "testrunner"), formatter = "Runic", completion = CompletionConfig(LaTeXEmojiConfig(missing), MethodSignatureConfig(missing)), + plugins = PluginSpec[], initialization_options = DEFAULT_INIT_OPTIONS) function get_default_config(path::Symbol...) diff --git a/src/workspace-configuration.jl b/src/workspace-configuration.jl index f5079090..3ffa27e4 100644 --- a/src/workspace-configuration.jl +++ b/src/workspace-configuration.jl @@ -112,6 +112,10 @@ function handle_lsp_config_change!(server::Server, tracker::ConfigChangeTracker, notify_diagnostics!(server) request_diagnostic_refresh!(server) end + if tracker.plugins_setting_changed + notify_diagnostics!(server) + request_diagnostic_refresh!(server) + end end function handle_DidChangeConfigurationNotification(server::Server, msg::DidChangeConfigurationNotification) diff --git a/test/runtests.jl b/test/runtests.jl index ccf43ad7..31405e22 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -35,6 +35,7 @@ end @testset "inlay hint" include("test_inlay_hint.jl") @testset "LSAnalyzer" include("test_Analyzer.jl") @testset "diagnostics" include("test_diagnostic.jl") + @testset "plugins" include("test_plugins.jl") @testset "did-change-watched-files" include("test_did_change_watched_files.jl") @testset "rename" include("test_rename.jl") @testset "testrunner" include("test_testrunner.jl") diff --git a/test/setup.jl b/test/setup.jl index 72b5c04c..76a405d9 100644 --- a/test/setup.jl +++ b/test/setup.jl @@ -167,6 +167,19 @@ function withserver(f; return (; raw_msg, json_msg) end + """ + try_readmsg() -> (; raw_msg, json_msg) + + Non-blocking variant of [`readmsg`](@ref). Returns `raw_msg === nothing` if no + message is available. + """ + function try_readmsg() + isready(sent_queue) || return (; raw_msg=nothing, json_msg=nothing) + raw_msg = take!(sent_queue) + json_msg = LSP.readlsp(out) + return (; raw_msg, json_msg) + end + # do the server initialization let id = id_counter[] += 1 (; raw_msg, raw_res) = writereadmsg( @@ -192,7 +205,7 @@ function withserver(f; end end - argnt = (; server, writemsg, readmsg, writereadmsg, id_counter) + argnt = (; server, writemsg, readmsg, try_readmsg, writereadmsg, id_counter) try # do the main callback return f(argnt) diff --git a/test/test_plugins.jl b/test/test_plugins.jl new file mode 100644 index 00000000..ba3bf036 --- /dev/null +++ b/test/test_plugins.jl @@ -0,0 +1,294 @@ +module test_plugins + +include("setup.jl") +include("jsjl-utils.jl") + +using Test +using JETLS +using JETLS.LSP +using JETLS.URIs2 + +function drain_server_messages!(; + try_readmsg, + writemsg, + max_reads::Int = 200, + ) + for _ = 1:max_reads + msg = try_readmsg().raw_msg + msg === nothing && break + if msg isa WorkspaceDiagnosticRefreshRequest + writemsg(WorkspaceDiagnosticRefreshResponse(; id=msg.id, result=nothing); check=false) + continue + end + if msg isa WorkDoneProgressCreateRequest + writemsg(WorkDoneProgressCreateResponse(; id=msg.id, result=nothing); check=false) + end + end + return nothing +end + +function wait_for_diagnostics_until!(; + try_readmsg, + writemsg, + uri::URI, + timeout_s::Real = 60, + predicate::Function = Returns(true), + ) + deadline = time() + timeout_s + last = nothing + while time() < deadline + msg = try_readmsg().raw_msg + if msg === nothing + sleep(0.05) + continue + end + if msg isa WorkspaceDiagnosticRefreshRequest + writemsg(WorkspaceDiagnosticRefreshResponse(; id=msg.id, result=nothing); check=false) + continue + end + if msg isa WorkDoneProgressCreateRequest + writemsg(WorkDoneProgressCreateResponse(; id=msg.id, result=nothing); check=false) + continue + end + msg isa PublishDiagnosticsNotification || continue + msg.params.uri == uri || continue + last = msg + if predicate(msg) + break + end + end + + last === nothing && error("Timeout waiting for PublishDiagnosticsNotification for $(uri)") + return last +end + +function read_until_diagnostics!(; + readmsg, + writemsg, + uri::URI, + max_reads::Int = 10, + ) + for _ = 1:max_reads + msg = readmsg(; read=1, check=false).raw_msg + if msg isa WorkspaceDiagnosticRefreshRequest + writemsg(WorkspaceDiagnosticRefreshResponse(; id=msg.id, result=nothing); check=false) + continue + end + if msg isa WorkDoneProgressCreateRequest + writemsg(WorkDoneProgressCreateResponse(; id=msg.id, result=nothing); check=false) + continue + end + msg isa PublishDiagnosticsNotification || continue + msg.params.uri == uri || continue + return msg + end + error("Timeout waiting for PublishDiagnosticsNotification for $(uri)") +end + +function diagnostic_codes(diags) + codes = Set{String}() + for d in diags + d.code isa String || continue + push!(codes, d.code) + end + return codes +end + +@testset "PluginSpec parsing accepts PackageSpec-like fields" begin + uuid_str = "f72c67d0-14ab-4d0b-9fc9-3ebf3a7d7d2b" + dict = Dict{String,Any}( + "plugins" => Any[ + Dict{String,Any}( + "name" => "TestPlugin", + "uuid" => uuid_str, + "version" => "1.2.3", + "url" => "https://example.invalid/repo.git", + "path" => "./local/path", + "subdir" => "subdir", + "rev" => "main", + "entry" => Any["entry1", "entry2"], + "enabled" => true, + # Accepted but ignored (Pkg.PackageSpec supports these). + "mode" => "ignored", + "level" => "ignored", + ), + ], + ) + + parsed = JETLS.parse_config_dict(dict) + @test parsed isa JETLS.JETLSConfig + + plugins = JETLS.getobjpath(parsed, :plugins) + @test plugins isa Vector{JETLS.PluginSpec} + @test length(plugins) == 1 + spec = plugins[1] + @test spec.name == "TestPlugin" + @test spec.uuid == Base.UUID(uuid_str) + @test spec.version == VersionNumber("1.2.3") + @test spec.url == "https://example.invalid/repo.git" + @test spec.path == "./local/path" + @test spec.subdir == "subdir" + @test spec.rev == "main" + @test spec.entry == ["entry1", "entry2"] + @test spec.enabled == true +end + +@testset "Plugin vector merge preserves overlay ordering" begin + a = JETLS.PluginSpec("A", nothing, nothing, nothing, nothing, nothing, nothing, String[], true) + b = JETLS.PluginSpec("B", nothing, nothing, nothing, nothing, nothing, nothing, String[], true) + base = JETLS.JETLSConfig(; plugins=[a, b]) + overlay = JETLS.JETLSConfig(; plugins=[b, a]) + merged = JETLS.merge_settings(base, overlay) + plugins = JETLS.getobjpath(merged, :plugins) + @test [p.name for p in plugins] == ["B", "A"] +end + +@testset "Plugin hooks run without restart (world-age safe)" begin + withpackage("TestPluginTarget", """ + module TestPluginTarget + function ok(x::Int) + return x + 1 + end + module Bad + function boom(x::Int) + return not_defined + x + end + end + end + """) do pkg_path + + # Create a local plugin package next to the target package. + tmp_root = dirname(pkg_path) + plugin_name = "TestJETLSPluginMock" + plugin_uuid = "3f48f4c2-3a63-4e8e-99f3-8b3a3b6dbd2a" + plugin_path = normpath(tmp_root, plugin_name) + mkpath(normpath(plugin_path, "src")) + write(normpath(plugin_path, "Project.toml"), """ + name = "$plugin_name" + uuid = "$plugin_uuid" + version = "0.1.0" + """) + write(normpath(plugin_path, "src", "$plugin_name.jl"), """ + __precompile__(false) + + module $plugin_name + + const _JETLS_PKGID = Base.PkgId(Base.UUID("a3b70258-0602-4ee2-b5a6-54c2470400db"), "JETLS") + const _JETLS = get(Base.loaded_modules, _JETLS_PKGID, nothing) + _JETLS === nothing && error("$plugin_name requires JETLS to be loaded") + const JETLS = _JETLS + + const CODE = "inference/test-plugin" + + struct Plugin <: JETLS.AbstractJETLSPlugin end + const PLUGIN = Plugin() + + function __init__() + JETLS.register_diagnostic_code!(CODE) + JETLS.register_plugin!(PLUGIN; owner=$(plugin_name)) + return nothing + end + + function _frame_uri(frame) + frame.file === :none && return nothing + filename = String(frame.file) + if startswith(filename, "Untitled") + return JETLS.filename2uri(filename) + end + return JETLS.filepath2uri(JETLS.to_full_path(filename)) + end + + function JETLS.plugin_expand_inference_error_report!( + ::Plugin, + uri2diagnostics::JETLS.URI2Diagnostics, + report::JETLS.JET.InferenceErrorReport, + ::JETLS.JET.PostProcessor, + )::Bool + for i in JETLS.Analyzer.inference_error_report_stack(report) + frame = report.vst[i] + uri = _frame_uri(frame) + uri === nothing && continue + haskey(uri2diagnostics, uri) || continue + line = JETLS.JET.fixed_line_number(frame) + line0 = max(line - 1, 0) + pos = JETLS.LSP.Position(line0, 0) + range = JETLS.LSP.Range(pos, pos) + diag = JETLS.LSP.Diagnostic(; + range, + severity = JETLS.LSP.DiagnosticSeverity.Information, + code = CODE, + source = "$plugin_name", + message = "test plugin active", + ) + push!(uri2diagnostics[uri], diag) + break + end + return false + end + + end # module + """) + + rootUri = filepath2uri(pkg_path) + src_path = normpath(pkg_path, "src", "TestPluginTarget.jl") + uri = filepath2uri(src_path) + code = read(src_path, String) + + withserver(; rootUri) do (; writemsg, readmsg, try_readmsg, writereadmsg) + # Open once with no plugins enabled. + (; raw_res) = writereadmsg(make_DidOpenTextDocumentNotification(uri, code); check=false) + @test raw_res isa PublishDiagnosticsNotification + pub1 = raw_res::PublishDiagnosticsNotification + codes1 = diagnostic_codes(pub1.params.diagnostics) + @test "inference/test-plugin" ∉ codes1 + drain_server_messages!(; try_readmsg, writemsg) + + # Enable the plugin after the server has started (this is where world-age matters). + settings = Dict{String,Any}( + "plugins" => Any[ + Dict{String,Any}( + "name" => plugin_name, + "uuid" => plugin_uuid, + "path" => "../$plugin_name", + "enabled" => true, + ), + ], + ) + writemsg( + DidChangeConfigurationNotification(; + params = DidChangeConfigurationParams(; settings) + ); + check=false, + ) + + # Drain server requests/notifications triggered by config change (e.g. refresh). + drain_server_messages!(; try_readmsg, writemsg) + + # Trigger a re-analysis so plugins are applied. + writemsg( + DidSaveTextDocumentNotification(; + params = DidSaveTextDocumentParams(; + textDocument = TextDocumentIdentifier(; uri), + text = code, + ) + ); + check=false, + ) + pub2 = read_until_diagnostics!(; + readmsg, + writemsg, + uri, + ) + codes2 = diagnostic_codes(pub2.params.diagnostics) + @test "inference/test-plugin" in codes2 + + # Drain any remaining server->client traffic to keep the test harness clean. + drain_server_messages!(; try_readmsg, writemsg) + sleep(0.05) + drain_server_messages!(; try_readmsg, writemsg) + return true + end + end +end + +end # module test_plugins