From cfa0a96a6eb687c9bc62815dc1847128fc0ede73 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Tue, 8 Jul 2025 00:48:27 +0900 Subject: [PATCH 1/2] implement `--trim` failure analyzer --- TrimAnalyzer/Project.toml | 22 +++ TrimAnalyzer/src/TrimAnalyzer.jl | 25 +++ TrimAnalyzer/src/TrimAnalyzerImpl.jl | 283 +++++++++++++++++++++++++++ TrimAnalyzer/src/app.jl | 232 ++++++++++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 TrimAnalyzer/Project.toml create mode 100644 TrimAnalyzer/src/TrimAnalyzer.jl create mode 100644 TrimAnalyzer/src/TrimAnalyzerImpl.jl create mode 100644 TrimAnalyzer/src/app.jl diff --git a/TrimAnalyzer/Project.toml b/TrimAnalyzer/Project.toml new file mode 100644 index 000000000..ae592a987 --- /dev/null +++ b/TrimAnalyzer/Project.toml @@ -0,0 +1,22 @@ +name = "TrimAnalyzer" +uuid = "db0f0d6f-36c4-4e19-a1b7-72446e3087f7" +version = "0.1.0" +authors = ["Shuhei Kadowaki "] + +[deps] +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +LSP = "880dcf91-6fde-4251-87fc-bfd84012291a" + +[sources] +JET = {rev = "master", url = "https://github.com/aviatesk/JET.jl"} +LSP = {path = "/Users/aviatesk/julia/packages/JETLS/LSP"} + +[compat] +InteractiveUtils = "1.11.0" +JET = "0.10.6" +JSON3 = "1.14.3" +LSP = "0.1" + +[apps.report-trim] diff --git a/TrimAnalyzer/src/TrimAnalyzer.jl b/TrimAnalyzer/src/TrimAnalyzer.jl new file mode 100644 index 000000000..2bda8de6b --- /dev/null +++ b/TrimAnalyzer/src/TrimAnalyzer.jl @@ -0,0 +1,25 @@ +module TrimAnalyzer + +export report_trim, @report_trim + +include("TrimAnalyzerImpl.jl") +using .TrimAnalyzerImpl: TrimAnalyzerImpl + +# Entry points +# ============ + +using InteractiveUtils: InteractiveUtils +using JET: JET + +function report_trim(args...; jetconfigs...) + analyzer = TrimAnalyzerImpl.TrimAnalyzer(; jetconfigs...) + return JET.analyze_and_report_call!(analyzer, args...; jetconfigs...) +end +macro report_trim(ex0...) + return InteractiveUtils.gen_call_with_extracted_types_and_kwargs(__module__, :report_trim, ex0) +end + +include("app.jl") +using .TrimAnalyzerApp: main + +end # module TrimAnalyzer diff --git a/TrimAnalyzer/src/TrimAnalyzerImpl.jl b/TrimAnalyzer/src/TrimAnalyzerImpl.jl new file mode 100644 index 000000000..0e8c0179e --- /dev/null +++ b/TrimAnalyzer/src/TrimAnalyzerImpl.jl @@ -0,0 +1,283 @@ +module TrimAnalyzerImpl + +using Core.IR +using JET.JETInterface +using JET: JET, CC + +struct TrimAnalyzer <: ToplevelAbstractAnalyzer + state::AnalyzerState + analysis_token::AnalysisToken + method_table::CC.CachedMethodTable{CC.OverlayMethodTable} + function TrimAnalyzer(state::AnalyzerState, analysis_token::AnalysisToken) + method_table = CC.CachedMethodTable(CC.OverlayMethodTable(state.world, TRIM_METHOD_TABLE)) + return new(state, analysis_token, method_table) + end +end +function TrimAnalyzer(state::AnalyzerState) + analysis_cache_key = JET.compute_hash(state.inf_params) + analysis_token = get!(AnalysisToken, TRIM_ANALYZER_CACHE, analysis_cache_key) + return TrimAnalyzer(state, analysis_token) +end + +# AbstractInterpreter API +# ======================= + +# TrimAnalyzer does not need any sources, so discard them always +CC.method_table(analyzer::TrimAnalyzer) = analyzer.method_table + +# AbstractAnalyzer API +# ==================== + +JETInterface.AnalyzerState(analyzer::TrimAnalyzer) = analyzer.state +function JETInterface.AbstractAnalyzer(analyzer::TrimAnalyzer, state::AnalyzerState) + return TrimAnalyzer(state, analyzer.analysis_token) +end +JETInterface.AnalysisToken(analyzer::TrimAnalyzer) = analyzer.analysis_token + +const TRIM_ANALYZER_CACHE = Dict{UInt, AnalysisToken}() + +# TRIM_METHOD_TABLE +# =============== + +using Base.Experimental: @overlay +Base.Experimental.@MethodTable TRIM_METHOD_TABLE + +@eval @overlay TRIM_METHOD_TABLE Core.DomainError(@nospecialize(val), @nospecialize(msg::AbstractString)) = (@noinline; $(Expr(:new, :DomainError, :val, :msg))) + +@overlay TRIM_METHOD_TABLE (f::Base.RedirectStdStream)(io::Core.CoreSTDOUT) = Base._redirect_io_global(io, f.unix_fd) + +@overlay TRIM_METHOD_TABLE Base.depwarn(msg, funcsym; force::Bool=false) = nothing +@overlay TRIM_METHOD_TABLE Base._assert_tostring(msg) = "" +@overlay TRIM_METHOD_TABLE Base.reinit_stdio() = nothing +@overlay TRIM_METHOD_TABLE Base.JuliaSyntax.enable_in_core!() = nothing +@overlay TRIM_METHOD_TABLE Base.init_active_project() = Base.ACTIVE_PROJECT[] = nothing +@overlay TRIM_METHOD_TABLE Base.set_active_project(projfile::Union{AbstractString,Nothing}) = Base.ACTIVE_PROJECT[] = projfile +@overlay TRIM_METHOD_TABLE Base.disable_library_threading() = nothing +@overlay TRIM_METHOD_TABLE Base.start_profile_listener() = nothing +@overlay TRIM_METHOD_TABLE Base.invokelatest(f, args...; kwargs...) = f(args...; kwargs...) +@overlay TRIM_METHOD_TABLE function Base.sprint(f::F, args::Vararg{Any,N}; context=nothing, sizehint::Integer=0) where {F<:Function,N} + s = IOBuffer(sizehint=sizehint) + if context isa Tuple + f(IOContext(s, context...), args...) + elseif context !== nothing + f(IOContext(s, context), args...) + else + f(s, args...) + end + String(Base._unsafe_take!(s)) +end +function show_typeish(io::IO, @nospecialize(T)) + if T isa Type + show(io, T) + elseif T isa TypeVar + print(io, (T::TypeVar).name) + else + print(io, "?") + end +end +@overlay TRIM_METHOD_TABLE function Base.show(io::IO, T::Type) + if T isa DataType + print(io, T.name.name) + if T !== T.name.wrapper && length(T.parameters) > 0 + print(io, "{") + first = true + for p in T.parameters + if !first + print(io, ", ") + end + first = false + if p isa Int + show(io, p) + elseif p isa Type + show(io, p) + elseif p isa Symbol + print(io, ":") + print(io, p) + elseif p isa TypeVar + print(io, p.name) + else + print(io, "?") + end + end + print(io, "}") + end + elseif T isa Union + print(io, "Union{") + show_typeish(io, T.a) + print(io, ", ") + show_typeish(io, T.b) + print(io, "}") + elseif T isa UnionAll + print(io, T.body::Type) + print(io, " where ") + print(io, T.var.name) + end +end +@overlay TRIM_METHOD_TABLE Base.show_type_name(io::IO, tn::Core.TypeName) = print(io, tn.name) + +@overlay TRIM_METHOD_TABLE Base.mapreduce(f::F, op::F2, A::Base.AbstractArrayOrBroadcasted; dims=:, init=Base._InitialValue()) where {F, F2} = + Base._mapreduce_dim(f, op, init, A, dims) +@overlay TRIM_METHOD_TABLE Base.mapreduce(f::F, op::F2, A::Base.AbstractArrayOrBroadcasted...; kw...) where {F, F2} = + reduce(op, map(f, A...); kw...) + +@overlay TRIM_METHOD_TABLE Base._mapreduce_dim(f::F, op::F2, nt, A::Base.AbstractArrayOrBroadcasted, ::Colon) where {F, F2} = + Base.mapfoldl_impl(f, op, nt, A) + +@overlay TRIM_METHOD_TABLE Base._mapreduce_dim(f::F, op::F2, ::Base._InitialValue, A::Base.AbstractArrayOrBroadcasted, ::Colon) where {F, F2} = + Base._mapreduce(f, op, IndexStyle(A), A) + +@overlay TRIM_METHOD_TABLE Base._mapreduce_dim(f::F, op::F2, nt, A::Base.AbstractArrayOrBroadcasted, dims) where {F, F2} = + Base.mapreducedim!(f, op, Base.reducedim_initarray(A, dims, nt), A) + +@overlay TRIM_METHOD_TABLE Base._mapreduce_dim(f::F, op::F2, ::Base._InitialValue, A::Base.AbstractArrayOrBroadcasted, dims) where {F,F2} = + Base.mapreducedim!(f, op, Base.reducedim_init(f, op, A, dims), A) + +@overlay TRIM_METHOD_TABLE Base.mapreduce_empty_iter(f::F, op::F2, itr, ItrEltype) where {F, F2} = + Base.reduce_empty_iter(Base.MappingRF(f, op), itr, ItrEltype) +@overlay TRIM_METHOD_TABLE Base.mapreduce_first(f::F, op::F2, x) where {F,F2} = Base.reduce_first(op, f(x)) + +@overlay TRIM_METHOD_TABLE Base._mapreduce(f::F, op::F2, A::Base.AbstractArrayOrBroadcasted) where {F,F2} = Base._mapreduce(f, op, Base.IndexStyle(A), A) +@overlay TRIM_METHOD_TABLE Base.mapreduce_empty(::typeof(identity), op::F, T) where {F} = Base.reduce_empty(op, T) +@overlay TRIM_METHOD_TABLE Base.mapreduce_empty(::typeof(abs), op::F, T) where {F} = abs(Base.reduce_empty(op, T)) +@overlay TRIM_METHOD_TABLE Base.mapreduce_empty(::typeof(abs2), op::F, T) where {F} = abs2(Base.reduce_empty(op, T)) + +@overlay TRIM_METHOD_TABLE Base.Sys.__init_build() = nothing + +# function __init__() +# try +# ccall((:__gmp_set_memory_functions, libgmp), Cvoid, +# (Ptr{Cvoid},Ptr{Cvoid},Ptr{Cvoid}), +# cglobal(:jl_gc_counted_malloc), +# cglobal(:jl_gc_counted_realloc_with_old_size), +# cglobal(:jl_gc_counted_free_with_size)) +# ZERO.alloc, ZERO.size, ZERO.d = 0, 0, C_NULL +# ONE.alloc, ONE.size, ONE.d = 1, 1, pointer(_ONE) +# catch ex +# Base.showerror_nostdio(ex, "WARNING: Error during initialization of module GMP") +# end +# # This only works with a patched version of GMP, ignore otherwise +# try +# ccall((:__gmp_set_alloc_overflow_function, libgmp), Cvoid, +# (Ptr{Cvoid},), +# cglobal(:jl_throw_out_of_memory_error)) +# ALLOC_OVERFLOW_FUNCTION[] = true +# catch ex +# # ErrorException("ccall: could not find function...") +# if typeof(ex) != ErrorException +# rethrow() +# end +# end +# end + +@overlay TRIM_METHOD_TABLE Base.Sort.issorted(itr; + lt::T=isless, by::F=identity, rev::Union{Bool,Nothing}=nothing, order::Base.Sort.Ordering=Forward) where {T,F} = + Base.Sort.issorted(itr, Base.Sort.ord(lt,by,rev,order)) + +@overlay TRIM_METHOD_TABLE function Base.TOML.try_return_datetime(p, year, month, day, h, m, s, ms) + return Base.TOML.DateTime(year, month, day, h, m, s, ms) +end +@overlay TRIM_METHOD_TABLE function Base.TOML.try_return_date(p, year, month, day) + return Base.TOML.Date(year, month, day) +end +@overlay TRIM_METHOD_TABLE function Base.TOML.parse_local_time(l::Base.TOML.Parser) + h = Base.TOML.@try Base.TOML.parse_int(l, false) + h in 0:23 || return Base.TOML.ParserError(Base.TOML.ErrParsingDateTime) + _, m, s, ms = Base.TOML.@try Base.TOML._parse_local_time(l, true) + # TODO: Could potentially parse greater accuracy for the + # fractional seconds here. + return Base.TOML.try_return_time(l, h, m, s, ms) +end +@overlay TRIM_METHOD_TABLE function Base.TOML.try_return_time(p, h, m, s, ms) + return Base.TOML.Time(h, m, s, ms) +end + +# analysis injections +# =================== + +function CC.abstract_call_gf_by_type(analyzer::TrimAnalyzer, + @nospecialize(func), arginfo::CC.ArgInfo, si::CC.StmtInfo, @nospecialize(atype), sv::CC.InferenceState, + max_methods::Int) + ret = @invoke CC.abstract_call_gf_by_type(analyzer::ToplevelAbstractAnalyzer, + func::Any, arginfo::CC.ArgInfo, si::CC.StmtInfo, atype::Any, sv::CC.InferenceState, max_methods::Int) + atype′ = Ref{Any}(atype) + function after_abstract_call_gf_by_type(analyzer′::TrimAnalyzer, sv′::CC.InferenceState) + ret′ = ret[] + report_dispatch_error!(analyzer′, sv′, ret′, atype′[]) + return true + end + if isready(ret) + after_abstract_call_gf_by_type(analyzer, sv) + else + push!(sv.tasks, after_abstract_call_gf_by_type) + end + return ret +end + +# analysis +# ======== + +# DispatchErrorReport +# ------------------- + +@jetreport struct DispatchErrorReport <: InferenceErrorReport + @nospecialize t # ::Union{Type, Vector{Type}} +end +JETInterface.print_report_message(io::IO, report::DispatchErrorReport) = print(io, "Unresolved call found") + +function is_inlineable(analyzer::TrimAnalyzer, match, info) + mi = CC.specialize_method(match; preexisting=true) + isnothing(mi) && return false + ci = get(CC.code_cache(analyzer), mi, nothing) + isnothing(ci) && return false + src = @atomic :monotonic ci.inferred + return CC.src_inlining_policy(analyzer, src, info, zero(UInt32)) +end + +function report_dispatch_error!(analyzer::TrimAnalyzer, sv::CC.InferenceState, call::CC.CallMeta, @nospecialize(atype)) + info = call.info + if info === CC.NoCallInfo() + report = DispatchErrorReport(sv, atype) + add_new_report!(analyzer, sv.result, report) + else + if info isa CC.ConstCallInfo + info = info.call + end + if info isa CC.MethodMatchInfo + for match in info.results + if (isnothing(CC.get_compileable_sig(match.method, match.spec_types, match.sparams)) && + !is_inlineable(analyzer, match, info)) + report = DispatchErrorReport(sv, atype) + add_new_report!(analyzer, sv.result, report) + end + end + else + @assert info isa CC.UnionSplitInfo + for info in info.split + for match in info.results + if (isnothing(CC.get_compileable_sig(match.method, match.spec_types, match.sparams)) && + !is_inlineable(analyzer, match, info)) + report = DispatchErrorReport(sv, atype) + add_new_report!(analyzer, sv.result, report) + end + end + end + end + end + return false +end + +# Constructor +# =========== + +# the entry constructor +function TrimAnalyzer(world::UInt = Base.get_world_counter(); jetconfigs...) + jetconfigs = JET.kwargs_dict(jetconfigs) + jetconfigs[:max_methods] = 3 + # jetconfigs[:assume_bindings_static] = true # TODO + state = AnalyzerState(world; jetconfigs...) + return TrimAnalyzer(state) +end + +JETInterface.valid_configurations(::TrimAnalyzer) = JET.GENERAL_CONFIGURATIONS + +end # module TrimAnalyzerImpl diff --git a/TrimAnalyzer/src/app.jl b/TrimAnalyzer/src/app.jl new file mode 100644 index 000000000..c314c432c --- /dev/null +++ b/TrimAnalyzer/src/app.jl @@ -0,0 +1,232 @@ +module TrimAnalyzerApp + +using ..TrimAnalyzer: report_trim +using JET: JET +using LSP +using LSP.URIs2 +using JSON3 + +function print_usage() + println("""TrimAnalyzer - Detect dispatch errors in Julia code + + Usage: + report-trim [options] + + Options: + --project[=] Set project/environment (same as Julia's --project) + --json Output results in JSON format + -h, --help Show this help message + + Examples: + report-trim example.jl + report-trim --json example.jl + report-trim --project=@. example.jl + report-trim --project=/path/to/project example.jl + """) +end + +# TODO Share code with JETLS.jl + +""" + fix_build_path(path::AbstractString) -> fixed_path::AbstractString + +If this Julia is a built one, convert `path` to `fixed_path`, which is a path to the main +files that are editable (or tracked by git). +""" +function fix_build_path end +let build_dir = normpath(Sys.BINDIR, "..", ".."), # with path separator at the end + share_path = normpath(Sys.BINDIR, Base.DATAROOTDIR, "julia") # without path separator at the end + global fix_build_path + if ispath(normpath(build_dir), "base") + build_path = splitdir(build_dir)[1] # remove the path separator + fix_build_path(path::AbstractString) = replace(path, share_path => build_path) + else + fix_build_path(path::AbstractString) = path + end +end + +to_full_path(file::Symbol) = to_full_path(String(file)) +function to_full_path(file::AbstractString) + file = Base.fixup_stdlib_path(file) + file = something(Base.find_source_file(file), file) + # TODO we should probably make this configurable + return fix_build_path(abspath(file)) +end + +function jet_frame_to_range(frame) + line = JET.fixed_line_number(frame) + return line_range(fixed_line_number(line)) +end + +fixed_line_number(line) = line == 0 ? line : line - 1 + +function line_range(line::Int) + start = Position(; line, character=0) + var"end" = Position(; line, character=Int(typemax(Int32))) + return Range(; start, var"end") +end + +function jet_inference_error_report_to_diagnostic(@nospecialize report::JET.InferenceErrorReport) + topframe = report.vst[1] + message = JET.with_bufferring(:limit=>true) do io + JET.print_report_message(io, report) + end + relatedInformation = DiagnosticRelatedInformation[ + let frame = report.vst[i], + message = sprint(JET.print_frame_sig, frame, JET.PrintConfig()) + DiagnosticRelatedInformation(; + location = Location(; + uri = filepath2uri(to_full_path(frame.file)), + range = jet_frame_to_range(frame)), + message) + end + for i = 2:length(report.vst)] + return Diagnostic(; + range = jet_frame_to_range(topframe), + severity = LSP.DiagnosticSeverity.Error, + message, + source = "TrimAnalyzer", + relatedInformation) +end + +module MainModule end + +function parse_project_path(project::String, filename::String) + if project == "@temp" + return mktempdir() + elseif project == "@." || project == "." + # Search for Project.toml in parent directories + dir = dirname(abspath(filename)) + while true + if isfile(joinpath(dir, "Project.toml")) || isfile(joinpath(dir, "JuliaProject.toml")) + return dir + end + parent = dirname(dir) + if parent == dir # Reached root + error("No Project.toml or JuliaProject.toml found in parent directories") + end + dir = parent + end + elseif startswith(project, "@script") + # Handle @script or @script format + scriptdir = dirname(abspath(filename)) + if project == "@script" + search_dir = scriptdir + else + # Extract relative path from @script + rel_path = project[8:end] # Remove "@script" prefix + search_dir = normpath(joinpath(scriptdir, rel_path)) + end + + # Search up from script directory + dir = search_dir + while true + if isfile(joinpath(dir, "Project.toml")) || isfile(joinpath(dir, "JuliaProject.toml")) + return dir + end + parent = dirname(dir) + if parent == dir # Reached root + error("No Project.toml or JuliaProject.toml found searching from $search_dir") + end + dir = parent + end + else + # Regular directory path + return project + end +end + +function (@main)(args::Vector{String}) + json_output = false + filepath = nothing + project = nothing + + i = 1 + while i <= length(args) + arg = args[i] + + if arg == "--json" + json_output = true + elseif arg == "-h" || arg == "--help" + print_usage() + return 0 + elseif startswith(arg, "--project=") + project = arg[11:end] + elseif arg == "--project" + # Handle --project without equals sign (use current directory) + project = "." + elseif startswith(arg, "-") + println(stderr, "Error: Unknown option: $arg") + println(stderr, "Run with --help to see available options") + return 1 + else + if filepath !== nothing + println(stderr, "Error: Multiple file paths provided") + return 1 + end + filepath = arg + end + i += 1 + end + + if filepath === nothing + println(stderr, "Error: No file path provided") + println(stderr) + print_usage() + return 1 + end + + if !isfile(filepath) + println(stderr, "Error: File not found: $filepath") + return 1 + end + + # Set up LOAD_PATH based on project + if Base.should_use_main_entrypoint() + empty!(LOAD_PATH) + push!(LOAD_PATH, "@", "@v$(VERSION.major).$(VERSION.minor)", "@stdlib") + end + + if project !== nothing + project_path = parse_project_path(project, filepath) + pushfirst!(LOAD_PATH, project_path) + end + + MainModule = Core.eval(Main, :(module MainModule end)) + try + Base.include(MainModule, filepath) + catch e + println(stderr, "Error loading file: ", e) + return 1 + end + + if !(@invokelatest isdefinedglobal(MainModule, :main)) + println(stderr, "Error: `main` is not defined in $filepath") + return 1 + end + + result = report_trim(@invokelatest(MainModule.main), (Vector{String},)) + + reports = JET.get_reports(result) + success = isempty(reports) + + if json_output + diagnostics = LSP.Diagnostic[] + if !success + for report in reports + push!(diagnostics, jet_inference_error_report_to_diagnostic(report)) + end + end + JSON3.write(stdout, (; + filepath, + success, + diagnostics)) + println(stdout) + else + show(stdout, result) + end + + return success ? 0 : 1 +end + +end # module TrimAnalyzerApp From bf4429393fcb5baa6c2b19079347af505741377e Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Fri, 19 Sep 2025 00:48:43 +0900 Subject: [PATCH 2/2] wip: add LS integration --- src/JETLS.jl | 2 + src/code-lens.jl | 3 + src/document-synchronization.jl | 5 + src/execute-command.jl | 36 +++ src/report-trim/report-trim-types.jl | 5 + src/report-trim/report-trim.jl | 322 +++++++++++++++++++++++++++ src/types.jl | 25 +++ src/utils/server.jl | 2 + 8 files changed, 400 insertions(+) create mode 100644 src/report-trim/report-trim-types.jl create mode 100644 src/report-trim/report-trim.jl diff --git a/src/JETLS.jl b/src/JETLS.jl index ee22b4863..74b8a38b8 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -49,6 +49,7 @@ const LWStats = JETLS_DEV_MODE ? AtomicContainers.LWStats : Nothing const CASStats = JETLS_DEV_MODE ? AtomicContainers.CASStats : Nothing include("testrunner/testrunner-types.jl") +include("report-trim/report-trim-types.jl") include("types.jl") include("utils/jl_syntax_macros.jl") @@ -83,6 +84,7 @@ include("formatting.jl") include("inlay-hint.jl") include("rename.jl") include("testrunner/testrunner.jl") +include("report-trim/report-trim.jl") include("did-change-watched-files.jl") include("lifecycle.jl") diff --git a/src/code-lens.jl b/src/code-lens.jl index 96eaf618f..1b7f503cd 100644 --- a/src/code-lens.jl +++ b/src/code-lens.jl @@ -34,6 +34,9 @@ function handle_CodeLensRequest(server::Server, msg::CodeLensRequest) testsetinfos = get_testsetinfos(server.state, uri) isnothing(testsetinfos) || testrunner_code_lenses!(code_lenses, uri, fi, testsetinfos) + entrypoints = get_entrypoints(server.state, uri) + isnothing(entrypoints) || + report_trim_code_lens!(code_lenses, uri, fi, entrypoints) return send(server, CodeLensResponse(; id = msg.id, diff --git a/src/document-synchronization.jl b/src/document-synchronization.jl index bcb7160cc..fa5689d7d 100644 --- a/src/document-synchronization.jl +++ b/src/document-synchronization.jl @@ -48,6 +48,7 @@ function handle_DidOpenTextDocumentNotification(server::Server, msg::DidOpenText parsed_stream = ParseStream!(textDocument.text) fi = cache_file_info!(server.state, uri, textDocument.version, parsed_stream) update_testsetinfos!(server, uri, fi) + update_entrypoint!(server, uri, fi) cache_saved_file_info!(server.state, uri, parsed_stream) request_analysis_on_open!(server, uri) @@ -62,6 +63,7 @@ function handle_DidChangeTextDocumentNotification(server::Server, msg::DidChange text = last(contentChanges).text fi = cache_file_info!(server.state, uri, textDocument.version, text) update_testsetinfos!(server, uri, fi) + update_entrypoint!(server, uri, fi) end function handle_DidSaveTextDocumentNotification(server::Server, msg::DidSaveTextDocumentNotification) @@ -100,6 +102,9 @@ function handle_DidCloseTextDocumentNotification(server::Server, msg::DidCloseTe store!(server.state.testsetinfos_cache) do cache Base.delete(cache, uri), nothing end + store!(server.state.entrypoints_cache) do cache + Base.delete(cache, uri), nothing + end if clear_extra_diagnostics!(server, uri) notify_diagnostics!(server) end diff --git a/src/execute-command.jl b/src/execute-command.jl index de17969e7..702a7dd34 100644 --- a/src/execute-command.jl +++ b/src/execute-command.jl @@ -6,11 +6,16 @@ const COMMAND_TESTRUNNER_RUN_TESTCASE = "JETLS.TestRunner.run@test" const COMMAND_TESTRUNNER_CLEAR_RESULT = "JETLS.TestRunner.clearResult" const COMMAND_TESTRUNNER_OPEN_LOGS = "JETLS.TestRunner.openLogs" +const COMMAND_REPORT_TRIM_RUN = "JETLS.ReportTrim.run" +const COMMAND_REPORT_TRIM_CLEAR_RESULT = "JETLS.ReportTrim.clearResult" + const SUPPORTED_COMMANDS = [ COMMAND_TESTRUNNER_RUN_TESTSET, COMMAND_TESTRUNNER_RUN_TESTCASE, COMMAND_TESTRUNNER_OPEN_LOGS, COMMAND_TESTRUNNER_CLEAR_RESULT, + COMMAND_REPORT_TRIM_RUN, + COMMAND_REPORT_TRIM_CLEAR_RESULT, ] function execute_command_options() @@ -42,6 +47,10 @@ function handle_ExecuteCommandRequest(server::Server, msg::ExecuteCommandRequest return execute_testrunner_open_logs_command(server, msg) elseif command == COMMAND_TESTRUNNER_CLEAR_RESULT return execute_testrunner_clear_result_command(server, msg) + elseif command == COMMAND_REPORT_TRIM_RUN + return execute_report_trim_run_command(server, msg) + elseif command == COMMAND_REPORT_TRIM_CLEAR_RESULT + return execute_report_trim_clear_result_command(server, msg) end return send(server, invalid_execute_command_response(msg, "Unknown execution command: $command")) @@ -140,3 +149,30 @@ function execute_testrunner_clear_result_command(server::Server, msg::ExecuteCom id = msg.id, result = null)) end + +function execute_report_trim_run_command(server::Server, msg::ExecuteCommandRequest) + uri = convert(URI, @tryparsearg server msg[1]::String) + error_msg = report_trim_run_from_uri(server, uri) + if error_msg !== nothing + show_error_message(server, error_msg) + return send(server, + ExecuteCommandResponse(; + id = msg.id, + result = nothing, + error = request_failed_error(error_msg))) + end + return send(server, + ExecuteCommandResponse(; + id = msg.id, + result = null)) +end + + +function execute_report_trim_clear_result_command(server::Server, msg::ExecuteCommandRequest) + uri = convert(URI, @tryparsearg server msg[1]::String) + try_clear_report_trim_result!(server, uri) + return send(server, + ExecuteCommandResponse(; + id = msg.id, + result = null)) +end diff --git a/src/report-trim/report-trim-types.jl b/src/report-trim/report-trim-types.jl new file mode 100644 index 000000000..8c49c8b4c --- /dev/null +++ b/src/report-trim/report-trim-types.jl @@ -0,0 +1,5 @@ +struct ReportTrimResult + filepath::String + success::Bool + diagnostics::Vector{Diagnostic} +end diff --git a/src/report-trim/report-trim.jl b/src/report-trim/report-trim.jl new file mode 100644 index 000000000..8c7e5b8aa --- /dev/null +++ b/src/report-trim/report-trim.jl @@ -0,0 +1,322 @@ +const REPORT_TRIM_DIAGNOSTIC_SOURCE = "JETLS - TrimAnalyzer" + +const REPORT_TRIM_RUN_TITLE = "▶ Run TrimAnalyzer" +const REPORT_TRIM_RERUN_TITLE = "▶ Rerun TrimAnalyzer" +const REPORT_TRIM_CLEAR_RESULT_TITLE = "✓ Clear result" + +function update_entrypoint!(server::Server, uri::URI, fi::FileInfo) + entrypoint = find_main_entrypoint(build_syntax_tree(fi)) + prev_entrypointinfo = fi.entrypointinfo + any_deleted = false + + if isnothing(entrypoint) + if !isnothing(prev_entrypointinfo) + if isdefined(prev_entrypointinfo, :result) + key = prev_entrypointinfo.result.key + any_deleted |= clear_extra_diagnostics!(server, key) + end + fi.entrypointinfo = nothing + end + else + if !isnothing(prev_entrypointinfo) + if isdefined(prev_entrypointinfo, :result) + # Preserve the result if the entrypoint hasn't changed + fi.entrypointinfo = Entrypoint(entrypoint, prev_entrypointinfo.result) + else + fi.entrypointinfo = Entrypoint(entrypoint) + end + else + fi.entrypointinfo = Entrypoint(entrypoint) + end + end + + if any_deleted + notify_diagnostics!(server) + end + + return fi.entrypointinfo +end + +# TODO support `function @main(args::Vector{String}) ... end` +function find_main_entrypoint(st0_top::SyntaxTree0) + for st0 in JS.children(st0_top) + if JS.kind(st0) === JS.K"function" && JS.numchildren(st0) >= 2 + st01 = st0[1] + if JS.kind(st01) === JS.K"call" && JS.numchildren(st01) >= 1 + st011 = st01[1] + if JS.kind(st011) === JS.K"macrocall" && JS.numchildren(st011) >= 1 + st0111 = st011[1] + if JS.kind(st0111) === JS.K"MacroName" && hasproperty(st0111, :name_val) + if st0111.name_val == "@main" + return st0 + end + end + end + end + end + end + return nothing +end + +function report_trim_code_lens!(code_lenses, uri::URI, fi::FileInfo) + entrypointinfo = fi.entrypointinfo + if isnothing(entrypointinfo) + return + end + + range = get_source_range(entrypointinfo.st0) + run_arguments = Any[uri] + + if isdefined(entrypointinfo, :result) + result = entrypointinfo.result.result + summary = result.success ? "✓ No dispatch errors" : "✗ $(length(result.diagnostics)) dispatch error(s)" + + command = Command(; + title = "$REPORT_TRIM_RERUN_TITLE $summary", + command = COMMAND_REPORT_TRIM_RUN, + arguments = run_arguments) + push!(code_lenses, CodeLens(; range, command)) + + command = Command(; + title = REPORT_TRIM_CLEAR_RESULT_TITLE, + command = COMMAND_REPORT_TRIM_CLEAR_RESULT, + arguments = run_arguments) + push!(code_lenses, CodeLens(; range, command)) + else + command = Command(; + title = REPORT_TRIM_RUN_TITLE, + command = COMMAND_REPORT_TRIM_RUN, + arguments = run_arguments) + push!(code_lenses, CodeLens(; range, command)) + end + + return code_lenses +end + +function report_trim_cmd(filepath::String, env_path::Union{Nothing,String}) + report_trim_exe = Sys.which("report-trim") + if isnothing(env_path) + return `$report_trim_exe --json $filepath` + else + return `$report_trim_exe --project=$env_path --json $filepath` + end +end + +function report_trim_result_to_diagnostics(result::ReportTrimResult) + uri2diagnostics = URI2Diagnostics() + uri = filename2uri(result.filepath) + isnothing(uri) && return uri2diagnostics + uri2diagnostics[uri] = result.diagnostics + return uri2diagnostics +end + +function report_trim_run(server::Server, uri::URI, fi::FileInfo, filepath::String; + token::Union{Nothing,ProgressToken}=nothing) + if isnothing(Sys.which("report-trim")) + show_error_message(server, """ + `report-trim` executable is not found on the `PATH`. + Please install TrimAnalyzer app to use this feature. + """) + return token !== nothing && end_report_trim_progress(server, token, "TrimAnalyzer not installed") + end + + if token !== nothing + send(server, ProgressNotification(; + params = ProgressParams(; + token, + value = WorkDoneProgressBegin(; + title = "Running TrimAnalyzer", + cancellable = false)))) + end + + local result::String + try + result = _report_trim_run(server, uri, fi, filepath) + catch err + result = sprint(Base.showerror, err, catch_backtrace()) + @error "Error from TrimAnalyzer executor" err + show_error_message(server, """ + An unexpected error occurred while running TrimAnalyzer: + See the server log for details. + """) + finally + @assert @isdefined(result) "`result` should be defined at this point" + if token !== nothing + end_report_trim_progress(server, token, result) + end + end +end + +function _report_trim_run(server::Server, uri::URI, fi::FileInfo, filepath::String) + env_path = find_uri_env_path(server.state, uri) + cmd = report_trim_cmd(filepath, env_path) + + proc = open(cmd; read=true, write=false) + output = read(proc, String) + wait(proc) + + result = try + JSONRPC.JSON3.read(output, ReportTrimResult) + catch err + @error "Error parsing TrimAnalyzer output" err output + show_error_message(server, """ + Failed to parse TrimAnalyzer output. + See the server log for details. + """) + return "Analysis failed" + end + + if !isnothing(fi.entrypointinfo) + key = ReportTrimDiagnosticsKey(fi) + report_trim_info = ReportTrimInfo(result, key) + fi.entrypointinfo = Entrypoint(fi.entrypointinfo.st0, report_trim_info) + + uri2diagnostics = report_trim_result_to_diagnostics(result) + if !isempty(result.diagnostics) + server.state.extra_diagnostics[key] = uri2diagnostics + elseif haskey(server.state.extra_diagnostics, key) + delete!(server.state.extra_diagnostics, key) + end + notify_diagnostics!(server) + + if supports(server, :workspace, :codeLens, :refreshSupport) + request_codelens_refresh!(server) + end + end + + show_report_trim_result_in_message(server, result, fi, uri) + + summary = result.success ? "✓ No dispatch errors found" : "✗ Found $(length(result.diagnostics)) dispatch error(s)" + return summary +end + +function show_report_trim_result_in_message(server::Server, result::ReportTrimResult, fi::FileInfo, uri::URI) + summary = result.success ? "✓ No dispatch errors found" : "✗ Found $(length(result.diagnostics)) dispatch error(s)" + message = "TrimAnalyzer: $summary" + + msg_type = if !result.success + MessageType.Error + else + MessageType.Info + end + + actions = MessageActionItem[ + MessageActionItem(; title = REPORT_TRIM_RERUN_TITLE), + MessageActionItem(; title = REPORT_TRIM_CLEAR_RESULT_TITLE) + ] + + id = String(gensym(:ShowMessageRequest)) + server.state.currently_requested[id] = ReportTrimMessageRequestCaller(uri, fi) + + send(server, ShowMessageRequest(; + id, + params = ShowMessageRequestParams(; + type = msg_type, + message, + actions))) +end + +function end_report_trim_progress(server::Server, token::ProgressToken, message::String) + send(server, ProgressNotification(; + params = ProgressParams(; + token, + value = WorkDoneProgressEnd(; message)))) +end + +struct ReportTrimMessageRequestCaller <: RequestCaller + uri::URI + fi::FileInfo +end + +struct ReportTrimProgressCaller <: RequestCaller + uri::URI + fi::FileInfo + filepath::String + token::ProgressToken +end + +function report_trim_run_from_uri(server::Server, uri::URI) + fi = get_file_info(server.state, uri) + if fi === nothing + return "File is no longer available in the editor" + end + + if isnothing(fi.entrypointinfo) + return "No @main function found in this file" + end + + sfi = get_saved_file_info(server.state, uri) + if sfi === nothing + return "The file appears not to exist on disk. Save the file first to run TrimAnalyzer." + elseif JS.sourcetext(fi.parsed_stream) ≠ JS.sourcetext(sfi.parsed_stream) + return "The editor state differs from the saved file. Save the file first to run TrimAnalyzer." + end + + filepath = uri2filepath(uri) + if isnothing(filepath) + return "Cannot determine file path for the URI" + end + + if supports(server, :window, :workDoneProgress) + id = String(gensym(:WorkDoneProgressCreateRequest_report_trim)) + token = String(gensym(:ReportTrimProgress)) + server.state.currently_requested[id] = ReportTrimProgressCaller(uri, fi, filepath, token) + params = WorkDoneProgressCreateParams(; token) + send(server, WorkDoneProgressCreateRequest(; id, params)) + else + @async report_trim_run(server, uri, fi, filepath) + end + + return nothing +end + + +function try_clear_report_trim_result!(server::Server, uri::URI) + fi = get_file_info(server.state, uri) + if fi === nothing || isnothing(fi.entrypointinfo) + return nothing + end + + if isdefined(fi.entrypointinfo, :result) + fi.entrypointinfo = Entrypoint(fi.entrypointinfo.st0) + + if clear_extra_diagnostics!(server, ReportTrimDiagnosticsKey(fi)) + notify_diagnostics!(server) + end + + if supports(server, :workspace, :codeLens, :refreshSupport) + request_codelens_refresh!(server) + end + end + + return nothing +end + +function handle_report_trim_message_response(server::Server, msg::Dict{Symbol,Any}, request_caller::ReportTrimMessageRequestCaller) + if handle_response_error(server, msg, "show TrimAnalyzer action") + return + elseif haskey(msg, :result) && msg[:result] !== nothing + selected = msg[:result] # ::MessageActionItem + title = get(selected, "title", "") + (; uri, fi) = request_caller + if title == REPORT_TRIM_RERUN_TITLE + error_msg = report_trim_run_from_uri(server, uri) + if error_msg !== nothing + show_error_message(server, error_msg) + end + elseif title == REPORT_TRIM_CLEAR_RESULT_TITLE + try_clear_report_trim_result!(server, uri) + else + error(lazy"Unknown action: $title") + end + end +end + +function handle_report_trim_progress_response(server::Server, msg::Dict{Symbol,Any}, request_caller::ReportTrimProgressCaller) + if handle_response_error(server, msg, "create work done progress") + return + end + # If successful, run TrimAnalyzer with progress reporting + (; uri, fi, filepath, token) = request_caller + @async report_trim_run(server, uri, fi, filepath; token) +end diff --git a/src/types.jl b/src/types.jl index de4a078ef..181232253 100644 --- a/src/types.jl +++ b/src/types.jl @@ -67,6 +67,28 @@ struct TestsetInfos infos::Vector{TestsetInfo} end +struct ReportTrimDiagnosticsKey <: ExtraDiagnosticsKey + uri::URI +end +to_uri_info_impl(key::ReportTrimDiagnosticsKey) = key.uri + +struct ReportTrimInfo + result::ReportTrimResult + key::ReportTrimDiagnosticsKey +end + +struct Entrypoint + st0::SyntaxTree0 + result::ReportTrimInfo + Entrypoint(st0::SyntaxTree0) = new(st0) + Entrypoint(st0::SyntaxTree0, result::ReportTrimInfo) = new(st0, result) +end + +struct Entrypoints + version::Int # document version + infos::Vector{Entrypoint} +end + entryuri(entry::AnalysisEntry) = entryuri_impl(entry)::URI entryenvpath(entry::AnalysisEntry) = entryenvpath_impl(entry)::Union{Nothing,String} entrykind(entry::AnalysisEntry) = entrykind_impl(entry)::String @@ -342,6 +364,7 @@ ConfigManagerData() = ConfigManagerData(ConfigDict(), WatchedConfigFiles()) const FileCache = SWContainer{Base.PersistentDict{URI,FileInfo}, SWStats} const SavedFileCache = SWContainer{Base.PersistentDict{URI,SavedFileInfo}, SWStats} const TestsetInfosCache = SWContainer{Base.PersistentDict{URI,TestsetInfos}, SWStats} +const EntrypointsCache = SWContainer{Base.PersistentDict{URI,Entrypoint}, SWStats} # Type aliases for concurrent updates using CASContainer (lightweight operations) const ExtraDiagnostics = CASContainer{ExtraDiagnosticsData, CASStats} @@ -374,6 +397,7 @@ mutable struct ServerState const file_cache::FileCache # syntactic analysis cache (synced with `textDocument/didChange`) const saved_file_cache::SavedFileCache # syntactic analysis cache (synced with `textDocument/didSave`) const testsetinfos_cache::TestsetInfosCache + const entrypoints_cache::EntrypointsCache const analysis_manager::AnalysisManager const extra_diagnostics::ExtraDiagnostics const currently_handled::CurrentlyHandled @@ -393,6 +417,7 @@ mutable struct ServerState #=file_cache=# FileCache(Base.PersistentDict{URI,FileInfo}()), #=saved_file_cache=# SavedFileCache(Base.PersistentDict{URI,SavedFileInfo}()), #=testsetinfos_cache=# TestsetInfosCache(Base.PersistentDict{URI,TestsetInfos}()), + #=entrypoints_cache=# EntrypointsCache(Base.PersistentDict{URI,Entrypoints}()), #=analysis_manager=# AnalysisManager(#=n_workers=# 1), # TODO multiple workers #=extra_diagnostics=# ExtraDiagnostics(ExtraDiagnosticsData()), #=currently_handled=# CurrentlyHandled(), diff --git a/src/utils/server.jl b/src/utils/server.jl index e70df6558..c396a7b75 100644 --- a/src/utils/server.jl +++ b/src/utils/server.jl @@ -180,6 +180,8 @@ get_saved_file_info(s::ServerState, t::TextDocumentIdentifier) = get_saved_file_ get_testsetinfos(s::ServerState, uri::URI) = get(load(s.testsetinfos_cache), uri, nothing) +get_entrypoints(s::ServerState, uri::URI) = get(load(s.entrypoints_cache), uri, nothing) + """ get_context_info(state::ServerState, uri::URI, pos::Position) -> (; mod, analyzer, postprocessor)