From 5e4a868a6857c4244be5cd46307ccd66b8f96549 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Mon, 17 Nov 2025 01:57:28 +0900 Subject: [PATCH] Reland "comm: the switch from JSON.jl to JSON3.jl (#300)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the migration to JSON.jl v1. As explained in aviatesk/JETLS.jl#300, JSON.jl v1 no longer supports out-of-box deserialization for custom data types and now requires explicit tags for each custom data type field (see https://publish.obsidian.md/jetls/work/JETLS/Switch+JSON3.jl+to+JSON.jl+v1#2025-11-16). This presents a significant challenge for LSP.jl, which uses the `@interface` DSL for TypeScript-like type definitions, because such tag generation must also be done through macro generation. Without this approach, we would need to scatter `@tags` macros and their associated `choosetype` definitions throughout the code base, which should be very very tedious. This commit further complicates the already complex `@interface` implementation to implement such automatic tag generation. While this macro has arguably become one of the most arcane Julia macros in existence, it does function correctly. Numerous tests have also been added. Most importantly, completing the switch to JSON v1 provides the following JSON communication performance improvements: ```julia using LSP uri = LSP.URIs2.filepath2uri(abspath("src/JETLS.jl")) s = """{ "jsonrpc": "2.0", "id": 0, "method": "textDocument/completion", "params": { "textDocument": { "uri": "$uri" }, "position": { "line": 0, "character": 0 }, "workDoneToken": "workDoneToken", "partialResultToken": "partialResultToken" } }"""; @time x = LSP.to_lsp_object(s) @time LSP.to_lsp_json(x) using BenchmarkTools @benchmark LSP.to_lsp_object($s) @benchmark LSP.to_lsp_json(x) setup=(x=LSP.to_lsp_object(s)) ``` > master ```julia-repl julia> @time x = LSP.to_lsp_object(s) 0.010983 seconds (328 allocations: 17.172 KiB, 90.51% compilation time) CompletionRequest("2.0", 0, "textDocument/completion", CompletionParams(TextDocumentIdentifier(file:///Users/aviatesk/julia/packages/JETLS/src/JETLS.jl), Position(0x0000000000000000, 0x0000000000000000), "workDoneToken", "partialResultToken", nothing)) julia> @time LSP.to_lsp_json(x) 0.000086 seconds (54 allocations: 3.500 KiB) "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///Users/aviatesk/julia/packages/JETLS/src/JETLS.jl\"},\"position\":{\"line\":0,\"character\":0},\"workDoneToken\":\"workDoneToken\",\"partialResultToken\":\"partialResultToken\"}}" julia> using BenchmarkTools julia> @benchmark LSP.to_lsp_object($s) BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample. Range (min … max): 357.500 μs … 537.833 μs ┊ GC (min … max): 0.00% … 0.00% Time (median): 370.291 μs ┊ GC (median): 0.00% Time (mean ± σ): 375.247 μs ± 17.525 μs ┊ GC (mean ± σ): 0.00% ± 0.00% █▆▂▃▇▅▃ ▁▃▇████████▆▅▅▅▄▄▃▃▂▂▂▂▂▂▂▂▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂ 358 μs Histogram: frequency by time 453 μs < Memory estimate: 5.91 KiB, allocs estimate: 120. julia> @benchmark LSP.to_lsp_json(x) setup=(x=LSP.to_lsp_object(s)) BenchmarkTools.Trial: 10000 samples with 10 evaluations per sample. Range (min … max): 1.871 μs … 862.158 μs ┊ GC (min … max): 0.00% … 98.95% Time (median): 2.104 μs ┊ GC (median): 0.00% Time (mean ± σ): 2.379 μs ± 10.872 μs ┊ GC (mean ± σ): 6.36% ± 1.40% ▄▇█▆▄▄▂▁ ▂▇█████████▇▅▅▄▄▄▃▄▃▃▃▃▃▂▂▂▂▂▂▂▂▂▂▂▂▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃ 1.87 μs Histogram: frequency by time 3.61 μs < Memory estimate: 2.70 KiB, allocs estimate: 36. ``` > this PR ```julia-repl julia> @time x = LSP.to_lsp_object(s) 0.010483 seconds (304 allocations: 15.422 KiB, 96.27% compilation time) CompletionRequest("2.0", 0, "textDocument/completion", CompletionParams(TextDocumentIdentifier(file:///Users/aviatesk/worktrees/JETLS/JSON-v1-again/src/JETLS.jl), Position(0x0000000000000000, 0x0000000000000000), "workDoneToken", "partialResultToken", nothing)) julia> @time LSP.to_lsp_json(x) 0.000095 seconds (69 allocations: 3.969 KiB) "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///Users/aviatesk/worktrees/JETLS/JSON-v1-again/src/JETLS.jl\"},\"position\":{\"line\":0,\"character\":0},\"workDoneToken\":\"workDoneToken\",\"partialResultToken\":\"partialResultToken\"}}" julia> using BenchmarkTools julia> @benchmark LSP.to_lsp_object($s) BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample. Range (min … max): 14.875 μs … 104.000 μs ┊ GC (min … max): 0.00% … 0.00% Time (median): 15.333 μs ┊ GC (median): 0.00% Time (mean ± σ): 15.612 μs ± 2.103 μs ┊ GC (mean ± σ): 0.00% ± 0.00% ▁▄▆█▇▇▆▆▄▃▂▁ ▂ █████████████▇▆▆▆▆▆▇▆▅▃▅▆▆▅▆▆▅▅▅▄▂▆▇▆▇▇█▆▅▆▆▆▆▅▆▅▆▅▅▄▄▄▃▂▄▂▃ █ 14.9 μs Histogram: log(frequency) by time 20.5 μs < Memory estimate: 4.45 KiB, allocs estimate: 83. julia> @benchmark LSP.to_lsp_json(x) setup=(x=LSP.to_lsp_object(s)) BenchmarkTools.Trial: 10000 samples with 9 evaluations per sample. Range (min … max): 2.181 μs … 1.146 ms ┊ GC (min … max): 0.00% … 99.38% Time (median): 2.361 μs ┊ GC (median): 0.00% Time (mean ± σ): 2.535 μs ± 11.437 μs ┊ GC (mean ± σ): 4.49% ± 0.99% ▃▄█▅▇▃▄▁▁ ▂▄▆█████████▇▇▅▅▄▄▃▃▂▂▂▂▂▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃ 2.18 μs Histogram: frequency by time 3.39 μs < Memory estimate: 2.48 KiB, allocs estimate: 34. ``` --- .JETLSConfig.toml | 8 - LSP/Project.toml | 10 +- LSP/README.md | 2 +- LSP/src/DSL/interface.jl | 290 +++++++++++++++++++++++++---------- LSP/src/LSP.jl | 21 ++- LSP/src/URIs2/URIs2.jl | 11 +- LSP/src/base-protocol.jl | 12 -- LSP/src/communication.jl | 20 +-- LSP/src/precompile.jl | 4 +- LSP/test/runtests.jl | 2 +- src/testrunner/testrunner.jl | 4 +- 11 files changed, 252 insertions(+), 132 deletions(-) diff --git a/.JETLSConfig.toml b/.JETLSConfig.toml index 339e75f45..0b5b7df60 100644 --- a/.JETLSConfig.toml +++ b/.JETLSConfig.toml @@ -18,14 +18,6 @@ match_type = "regex" severity = "off" path = "LSP/src/**/*.jl" -# This is type instability within JSON3.jl -[[diagnostic.patterns]] -pattern = "no matching method found `convert(.+)`" -match_by = "message" -match_type = "regex" -severity = "off" -path = "src/testrunner/testrunner-types.jl" - # Treat all other diagnostics as warnings (for early detection via jetls check) [[diagnostic.patterns]] pattern = ".*" diff --git a/LSP/Project.toml b/LSP/Project.toml index e508a4c77..d6ab5bb32 100644 --- a/LSP/Project.toml +++ b/LSP/Project.toml @@ -7,11 +7,13 @@ authors = ["Shuhei Kadowaki "] projects = ["test"] [deps] -JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" -StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" +StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" [compat] -JSON3 = "1.14.3" +JSON = "1.5" PrecompileTools = "1.3.3" -StructTypes = "1.11.0" +Preferences = "1.5.0" +StructUtils = "2.6.0" diff --git a/LSP/README.md b/LSP/README.md index 17ccf1a3e..d4533a599 100644 --- a/LSP/README.md +++ b/LSP/README.md @@ -15,7 +15,7 @@ to faithfully translate the TypeScript LSP specification into idiomatic Julia co - Uses `Union{Nothing, Type} = nothing` field to represent TypeScript's optional properties (`field?: Type`) - Supports inheritance through `@extends` to compose interfaces (similar to TypeScript's `extends`) - Enables anonymous interface definitions within `Union` types for inline type specifications - - Automatically configures `StructTypes.omitempties()` to omit optional fields during JSON serialization + - Integrates with JSON.jl v1 and StructUtils.jl for JSON serialization/deserialization - Creates method dispatchers for `RequestMessage` and `NotificationMessage` types to enable LSP message routing - **`@namespace` macro**: Creates Julia modules containing typed constants that correspond to TypeScript `namespace`s: diff --git a/LSP/src/DSL/interface.jl b/LSP/src/DSL/interface.jl index 897c62307..158d20fa7 100644 --- a/LSP/src/DSL/interface.jl +++ b/LSP/src/DSL/interface.jl @@ -1,5 +1,7 @@ const _interface_defs_ = Dict{Symbol,Expr}() +const _debug_ = Dict{Symbol,Pair{Expr,Dict{Symbol,Union{Nothing,Pair{Expr,Any}}}}}() + """ @interface InterfaceName [@extends ParentInterface] begin field::Type @@ -13,6 +15,9 @@ definitions from the LSP specification, featuring: - Optional fields: Use `Union{Nothing, Type} = nothing` to represent TypeScript's optional properties (`field?: Type`) - Interface inheritance: Use `@extends` to compose interfaces from parent interfaces (similar to TypeScript's `extends`) - Anonymous interfaces: Can be used inline within field type declarations (e.g., `Union{Nothing, @interface begin ... end}`) +- JSON serialization: Integrates with JSON.jl v1 and StructUtils.jl for automatic JSON serialization/deserialization +- Automatic type choosing: For fields with `Union` types, automatically generates `StructUtils.fieldtags` implementations + that inspect the JSON structure to determine the correct type during deserialization - Method dispatching: For interfaces extending `RequestMessage` or `NotificationMessage`, automatically registers the message type in the `method_dispatcher` dictionary for LSP message routing @@ -61,6 +66,23 @@ end When a child interface defines a field with the same name as a parent interface, the child's definition takes precedence. +# Automatic type choosing for JSON deserialization + +For fields with `Union` types, `@interface` automatically generates `StructUtils.fieldtags` +implementations that analyze the JSON structure to determine the correct type during +deserialization. This enables automatic conversion of polymorphic fields common in the LSP +specification, e.g.: +```julia +@interface Hover begin + contents::Union{MarkedString, Vector{MarkedString}, MarkupContent} + range::Union{Nothing, Range} = nothing +end + +@interface WorkspaceEdit begin + documentChanges::Union{Nothing, Vector{Union{TextDocumentEdit, CreateFile, RenameFile, DeleteFile}}} = nothing +end +``` + # LSP message dispatching For interfaces that extend `RequestMessage` or `NotificationMessage`, a `method::String` @@ -110,25 +132,24 @@ macro interface(exs...) is_method_dispatchable = false structbody = Expr(:block, __source__) - omittable_fields = Set{Symbol}() extended_fields = Dict{Symbol,Vector{Int}}() duplicated_fields = Int[] if extends !== nothing for extend in extends is_method_dispatchable |= extend === :RequestMessage || extend === :NotificationMessage add_extended_interface!(toplevelblk, structbody, - omittable_fields, extended_fields, duplicated_fields, extend, + __module__, __source__) end end _, method = process_interface_def!(toplevelblk, structbody, - omittable_fields, extended_fields, duplicated_fields, defex, + __module__, __source__, Name) @@ -145,72 +166,57 @@ macro interface(exs...) end function process_interface_def!(toplevelblk::Expr, structbody::Expr, - omittable_fields::Set{Symbol}, extended_fields::Dict{Symbol,Vector{Int}}, duplicated_fields::Vector{Int}, defex::Expr, + __module__::Module, __source__::LineNumberNode, Name::Union{Symbol,Nothing}) - method = _process_interface_def!(toplevelblk, structbody, omittable_fields, extended_fields, duplicated_fields, defex, __source__) - deleteat!(structbody.args, duplicated_fields) is_anon = Name === nothing if is_anon # Name = Symbol("AnonymousInterface", string(__source__)) # XXX this doesn't work probably due to Julia internal bug Name = Symbol("AnonymousInterface", gensym()) end + method, fieldtags_ex = _process_interface_def!( + toplevelblk, structbody, extended_fields, + duplicated_fields, defex, __module__, __source__; + struct_name = Name) + deleteat!(structbody.args, duplicated_fields) structdef = Expr(:struct, false, Name, structbody) + # TODO Use `StructUtils.@defaults` here? kwdef = Expr(:macrocall, GlobalRef(Base, Symbol("@kwdef")), __source__, structdef) # `@kwdef` will attach `Core.__doc__` automatically push!(toplevelblk.args, kwdef) - if !isempty(omittable_fields) - omitempties = Tuple(omittable_fields) - if Name === :InitializeParams - # HACK: In the write->read roundtrip of `InitializationRequest` in the - # `withserver` test, empty `workspaceFolders` needs to be serialized without - # being omitted. So override the `omitempties` for `InitializeParams`. - # In the normal lifecycle of a language server, `InitializeParams` is never - # serialized, so adding this hack doesn't affect the language server's behavior - # (except in the tests) - omitempties = () - end - push!(toplevelblk.args, :(StructTypes.omitempties(::Type{$Name}) = $omitempties)) - end if is_anon push!(toplevelblk.args, :(Base.convert(::Type{$Name}, nt::NamedTuple) = $Name(; nt...))) end - if !is_anon - push!(toplevelblk.args, :($(GlobalRef(@__MODULE__, :_interface_defs_))[$(QuoteNode(Name))] = $(QuoteNode(structbody)))) + push!(toplevelblk.args, :($(GlobalRef(@__MODULE__, :_interface_defs_))[$(QuoteNode(Name))] = $(QuoteNode(structbody)))) + if fieldtags_ex !== nothing + push!(toplevelblk.args, fieldtags_ex) end return Name, method end -function add_extended_interface!(toplevelblk::Expr, structbody::Expr, - omittable_fields::Set{Symbol}, - extended_fields::Dict{Symbol,Vector{Int}}, - duplicated_fields::Vector{Int}, - extend::Symbol, - __source__::LineNumberNode) - return _process_interface_def!(toplevelblk, structbody, - omittable_fields, - extended_fields, - duplicated_fields, - _interface_defs_[extend], - __source__; - extending = true) +function add_extended_interface!( + toplevelblk::Expr, structbody::Expr, extended_fields::Dict{Symbol,Vector{Int}}, + duplicated_fields::Vector{Int}, extend::Symbol, __module__::Module, __source__::LineNumberNode + ) + _process_interface_def!( + toplevelblk, structbody, extended_fields, + duplicated_fields, _interface_defs_[extend], __module__, __source__; + extending = true) + nothing end -function _process_interface_def!(toplevelblk::Expr, structbody::Expr, - omittable_fields::Set{Symbol}, - extended_fields::Dict{Symbol,Vector{Int}}, - duplicated_fields::Vector{Int}, - defex::Expr, - __source__::LineNumberNode; - extending::Bool = false) +function _process_interface_def!( + toplevelblk::Expr, structbody::Expr, extended_fields::Dict{Symbol,Vector{Int}}, + duplicated_fields::Vector{Int}, defex::Expr, __module__::Module, __source__::LineNumberNode; + extending::Bool = false, struct_name::Union{Nothing,Symbol} = nothing, + ) @assert Meta.isexpr(defex, :block) extended_idxs = Int[] method = nothing for i = 1:length(defex.args) - defarg = defex.args[i] - fieldline = defarg + fieldline = defex.args[i] if fieldline isa LineNumberNode || fieldline isa String if fieldline isa LineNumberNode __source__ = fieldline @@ -234,75 +240,193 @@ function _process_interface_def!(toplevelblk::Expr, structbody::Expr, error("Unsupported syntax found in `@interface`: ", defex) end end - omittable = false if Meta.isexpr(fieldline, :(=)) - fielddecl, default = fieldline.args - if Meta.isexpr(fielddecl, :(::)) - fieldname = fielddecl.args[1] + fdecl, default = fieldline.args + if Meta.isexpr(fdecl, :(::)) + fname = fdecl.args[1] else - fieldname = fielddecl + fname = fdecl end - fieldname isa Symbol || error("Invalid `@interface` syntax: ", defex) - if fieldname === :method + fname isa Symbol || error("Invalid `@interface` syntax: ", defex) + if fname === :method default isa String || error("Invalid message definition: ", defex) method isa String && error("Duplicated method definition: ", defex) method = default end else - fielddecl = fieldline + fdecl = fieldline end - if Meta.isexpr(fielddecl, :(::)) - fieldname = fielddecl.args[1] - fieldtype = fielddecl.args[2] - if Meta.isexpr(fieldtype, :curly) && fieldtype.args[1] === :Union - for i = 2:length(fieldtype.args) - ufty = fieldtype.args[i] - if ufty === :Nothing - omittable = true - end - end - end - if Meta.isexpr(fieldtype, :curly) - for i = 1:length(fieldtype.args) - ufty = fieldtype.args[i] + if Meta.isexpr(fdecl, :(::)) + fname = fdecl.args[1] + ftype = fdecl.args[2] + if Meta.isexpr(ftype, :curly) + for i = 1:length(ftype.args) + ufty = ftype.args[i] if Meta.isexpr(ufty, :macrocall) && ufty.args[1] === Symbol("@interface") anon_defex = ufty.args[end] Meta.isexpr(anon_defex, :block) || error("Invalid `@interface` syntax: ", ufty) - fieldtype.args[i] = process_anon_interface_def!(toplevelblk, anon_defex, __source__) + ftype.args[i] = process_anon_interface_def!(toplevelblk, anon_defex, __module__, __source__) end end - elseif Meta.isexpr(fieldtype, :macrocall) && fieldtype.args[1] === Symbol("@interface") - anon_defex = fieldtype.args[end] - Meta.isexpr(anon_defex, :block) || error("Invalid `@interface` syntax: ", fieldtype) - fielddecl.args[2] = process_anon_interface_def!(toplevelblk, anon_defex, __source__) + elseif Meta.isexpr(ftype, :macrocall) && ftype.args[1] === Symbol("@interface") + anon_defex = ftype.args[end] + Meta.isexpr(anon_defex, :block) || error("Invalid `@interface` syntax: ", ftype) + fdecl.args[2] = process_anon_interface_def!(toplevelblk, anon_defex, __module__, __source__) end else - fieldname = fielddecl + fname = fdecl end - fieldname isa Symbol || error("Invalid `@interface` syntax: ", defex) - if omittable - push!(omittable_fields, fieldname) - end - if haskey(extended_fields, fieldname) - append!(duplicated_fields, extended_fields[fieldname]) - omittable || delete!(omittable_fields, fieldname) + fname isa Symbol || error("Invalid `@interface` syntax: ", defex) + if haskey(extended_fields, fname) + append!(duplicated_fields, extended_fields[fname]) end push!(structbody.args, fieldline) if extending push!(extended_idxs, length(structbody.args)) - extended_fields[fieldname] = copy(extended_idxs) + extended_fields[fname] = copy(extended_idxs) empty!(extended_idxs) end end - return method + if struct_name !== nothing + fieldtags_ex = :(let + ntparams = Expr(:parameters) + body = Expr(:tuple, ntparams) + choosetypes = Dict{Symbol,Union{Nothing,Pair{Expr,Any}}}() + for fname in fieldnames($struct_name) + choosefunc_ex = $gen_choosetype_impl($struct_name, fname) + if choosefunc_ex !== nothing + let choosetypefunc = Core.eval($__module__, choosefunc_ex) + # `fname = (; json = (; choosetype = choosetypefunc))` + push!(ntparams.args, Expr(:kw, fname, + Expr(:tuple, Expr(:parameters, Expr(:kw, :json, + Expr(:tuple, Expr(:parameters, Expr(:kw, :choosetype, choosetypefunc)))))))) + choosetypes[fname] = Pair{Expr,Any}(choosefunc_ex, choosetypefunc) + end + else + choosetypes[fname] = nothing + end + end + if !isempty(ntparams.args) + Core.eval($__module__, :($StructUtils.fieldtags(::$StructUtils.StructStyle, ::Type{$$struct_name}) = $body)) + end + if $LSP_DEV_MODE + $_debug_[$(QuoteNode(struct_name))] = body => choosetypes + end + end) + else + fieldtags_ex = nothing + end + return method, fieldtags_ex +end + +# This function is executed _after_ the type definition generated using `@interface`, and +# generatesan implementation of `StructUtils.choose_type` for custom type JSON +# deserialization for the fields of that type definition. Therefore, the code generated by +# `@interface` contains not the code generated by this function itself, but the call to this +# function (and also includes `Core.eval` which is the driver for code execution using it). +# This design is intentional, as accurate type dispatch generation requires the use of +# reflection such as `fieldtype`, and also needs to handle anonymous interface types +# that are potentially included in `@interface` definitions, making it difficult to perform +# this transformation completely symbolically, leading to the design of calling this +# function after the actual type definition. +function gen_choosetype_impl(StructType::Type, fname::Symbol) + ftyp = fieldtype(StructType, fname) + ftyp isa Union || return nothing + choosetypebody = Expr(:block) + lvname = gensym(:lv) + vname = gensym(:x) + push!(choosetypebody.args, :($vname = $lvname[])) + cond_and_rets = Pair{Any,Any}[] + res = add_choosetype_branch!(cond_and_rets, vname, ftyp) + iszero(res & REQUIRES_CHOOSETYPE) && return nothing # JSON.jl shuold handle this field automatically + errmsg = "Uncovered field type: $fname::$ftyp in $StructType" + for (cond, ret) in cond_and_rets + push!(choosetypebody.args, Expr(:(&&), cond, ret)) + end + push!(choosetypebody.args, :(error($errmsg))) + return Expr(:->, :($lvname::JSON.LazyValue), choosetypebody) +end + +const JSONJL_NOTHING = 1<<0 +const JSONJL_PRIMITIVE = 1<<1 +const REQUIRES_CHOOSETYPE_IF_UNION = 1<<2 +const REQUIRES_CHOOSETYPE = 1<<3 +const CONTAINS_PARAMETRIC_DICT = 1<<4 + +function add_choosetype_branch!(cond_and_rets, vname::Symbol, @nospecialize ftyp) + if ftyp isa Union + abit = add_choosetype_branch!(cond_and_rets, vname, ftyp.a) + bbit = add_choosetype_branch!(cond_and_rets, vname, ftyp.b) + if !iszero(abit & CONTAINS_PARAMETRIC_DICT) && !iszero(bbit & CONTAINS_PARAMETRIC_DICT) + error("`@interface` declaration with multiple Dict types unsupported") + end + abbit = abit | bbit + if !iszero(abit & REQUIRES_CHOOSETYPE_IF_UNION) && !iszero(bbit & REQUIRES_CHOOSETYPE_IF_UNION) + # e.g. require `choosetype` for `Union{MyType1,MyType2}`, `Union{Union{Nothing,MyType1},MyType2}` + abbit |= REQUIRES_CHOOSETYPE + end + if (abbit & (JSONJL_PRIMITIVE | REQUIRES_CHOOSETYPE_IF_UNION)) == (JSONJL_PRIMITIVE | REQUIRES_CHOOSETYPE_IF_UNION) + # e.g. require `choosetype` for `Union{String,MyType2}` + abbit |= REQUIRES_CHOOSETYPE + end + return abbit + end + if ftyp <: Vector + ftyp = ftyp::DataType + cond_and_rets′ = Pair{Any,Any}[] + a1name = gensym(:a1) + eltype = ftyp.parameters[1] + add_choosetype_branch!(cond_and_rets′, a1name, eltype) + elcond = reduce(cond_and_rets′; init=false) do @nospecialize(acc), (cond, _)::Pair{Any,Any} + Expr(:(||), acc, cond) + end + elcond === false && error(lazy"Unexpected parameterized vector type found in field declaration: $ftyp") + push!(cond_and_rets, Pair{Any,Any}( + :($vname isa Vector{Any} && + (isempty($vname) || let $a1name = first($vname); $elcond; end)), + :(return $ftyp))) + return REQUIRES_CHOOSETYPE_IF_UNION + elseif ftyp <: Dict + push!(cond_and_rets, Pair{Any,Any}(:($vname isa JSON.Object), :(return $ftyp))) + return REQUIRES_CHOOSETYPE_IF_UNION | CONTAINS_PARAMETRIC_DICT + elseif ftyp === Nothing + push!(cond_and_rets, Pair{Any,Any}(:($vname isa Nothing), :(return Nothing))) + return JSONJL_NOTHING + elseif ftyp in (Any, Bool, Int, UInt, String) + push!(cond_and_rets, Pair{Any,Any}(:($vname isa $ftyp), :(return $ftyp))) + return JSONJL_PRIMITIVE + elseif ftyp === Null + # NOTE This should be `pushfirst!` to consider the case where `Nothing` is in a `Union`. + # When `Nothing` and `Null` coexist, at the point this callback is called, it is confirmed + # that `null` has been explicitly called as the field value, and in that case we should return `null` + pushfirst!(cond_and_rets, Pair{Any,Any}(:($vname isa Nothing), :(return $Null))) + return REQUIRES_CHOOSETYPE_IF_UNION + elseif ftyp === URI + # NOTE This should be `pushfirst!` to consider the case where `Nothing` is in a `Union`. + # When `Nothing` and `Null` coexist, at the point this callback is called, it is confirmed + # that `null` has been explicitly called as the field value, and in that case we should return `null` + pushfirst!(cond_and_rets, Pair{Any,Any}(:($vname isa String), :(return $URI))) + return REQUIRES_CHOOSETYPE_IF_UNION + end + condex = let ftyp=ftyp + reduce(fieldnames(ftyp); init=true) do @nospecialize(acc), fname::Symbol + if typeintersect(fieldtype(ftyp, fname), Nothing) === Nothing + return acc # omittable + end + Expr(:(&&), acc, :(haskey($vname, $(QuoteNode(fname))))) + end + end + push!(cond_and_rets, Pair{Any,Any}(:($vname isa JSON.Object && $condex), :(return $ftyp))) + return REQUIRES_CHOOSETYPE_IF_UNION end -function process_anon_interface_def!(toplevelblk::Expr, defex::Expr, __source__::LineNumberNode) # Anonymous @interface - omittable_fields = Set{Symbol}() +function process_anon_interface_def!( # Anonymous @interface + toplevelblk::Expr, defex::Expr, __module__::Module, __source__::LineNumberNode + ) extended_fields = Dict{Symbol,Vector{Int}}() duplicated_fields = Int[] res, _ = process_interface_def!(toplevelblk, Expr(:block), - omittable_fields, extended_fields, duplicated_fields, defex, __source__, #=Name=#nothing) + extended_fields, duplicated_fields, defex, __module__, __source__, #=Name=#nothing) if !(isempty(extended_fields) && isempty(duplicated_fields)) error("`Anonymous @interface` does not support extension", defex) end diff --git a/LSP/src/LSP.jl b/LSP/src/LSP.jl index f6af51a43..2bec47c65 100644 --- a/LSP/src/LSP.jl +++ b/LSP/src/LSP.jl @@ -1,13 +1,32 @@ module LSP -using StructTypes: StructTypes +using StructUtils: StructUtils +using JSON: JSON + +using Preferences: Preferences +const LSP_DEV_MODE = Preferences.@load_preference("LSP_DEV_MODE", false) include("URIs2/URIs2.jl") using ..URIs2: URI const exports = Set{Symbol}() + const method_dispatcher = Dict{String,DataType}() +# NOTE `Null` and `URI` are referenced directly from interface.jl, so it should be defined before that. + +""" +A special object representing `null` value. +When used as a field that might be omitted in the serialized JSON (i.e. the field can be `nothing`), +the key-value pair appears as `null` instead of being omitted. +This special object is specifically intended for use in `ResponseMessage`. +""" +StructUtils.@nonstruct struct Null end +const null = Null() +Base.show(io::IO, ::Null) = print(io, "null") +StructUtils.lower(::Null) = JSON.Null() +push!(exports, :Null, :null) + include("DSL/interface.jl") include("DSL/namespace.jl") diff --git a/LSP/src/URIs2/URIs2.jl b/LSP/src/URIs2/URIs2.jl index 3ec36efa1..3d2038ec7 100644 --- a/LSP/src/URIs2/URIs2.jl +++ b/LSP/src/URIs2/URIs2.jl @@ -7,7 +7,7 @@ module URIs2 export @uri_str, URI, filename2uri, filepath2uri, isunsavedfile, isunsaveduri, uri2filename, uri2filepath -using StructTypes: StructTypes +using StructUtils: StructUtils include("vendored-from-uris.jl") @@ -22,7 +22,7 @@ Details of a Unified Resource Identifier. - query::Union{Nothing, String} - fragment::Union{Nothing, String} """ -struct URI +StructUtils.@nonstruct struct URI scheme::Union{String,Nothing} authority::Union{String,Nothing} path::String @@ -72,10 +72,9 @@ else end end -Base.convert(::Type{URI}, s::AbstractString) = URI(s) - -# This overload requires `URI(::AbstractString)` as well, which is defined later -StructTypes.StructType(::Type{URI}) = StructTypes.StringType() +# Tell StructUtils how to serialize/deserialize URI as a string +StructUtils.lower(uri::URI) = string(uri) +StructUtils.lift(::Type{URI}, s::String) = URI(s) function percent_decode(str::AbstractString) return unescapeuri(str) diff --git a/LSP/src/base-protocol.jl b/LSP/src/base-protocol.jl index 1b15e916a..b8d184a28 100644 --- a/LSP/src/base-protocol.jl +++ b/LSP/src/base-protocol.jl @@ -1,18 +1,6 @@ # Base types # ========= -""" -A special object representing `null` value. -When used as a field specified as `StructTypes.omitempties`, the key-value pair is not -omitted in the serialized JSON but instead appears as `null`. -This special object is specifically intended for use in `ResponseMessage`. -""" -struct Null end -const null = Null() -StructTypes.StructType(::Type{Null}) = StructTypes.CustomStruct() -StructTypes.lower(::Null) = nothing -push!(exports, :Null, :null) - const boolean = Bool const string = String diff --git a/LSP/src/communication.jl b/LSP/src/communication.jl index b3b71e98f..b50f089ea 100644 --- a/LSP/src/communication.jl +++ b/LSP/src/communication.jl @@ -1,5 +1,3 @@ -using JSON3: JSON3 - """ Endpoint @@ -115,19 +113,17 @@ function read_transport_layer(io::IO) return String(read(io, message_length)) end -const Parsed = @NamedTuple{method::Union{Nothing,String}} - function to_lsp_object(msg_str::AbstractString) - parsed = JSON3.read(msg_str, Parsed) - parsed_method = parsed.method - if parsed_method !== nothing - if haskey(method_dispatcher, parsed_method) - return JSON3.read(msg_str, method_dispatcher[parsed_method]) + lazyjson = JSON.lazy(msg_str) + if hasproperty(lazyjson, :method) + method = lazyjson.method[] + if method isa String && haskey(method_dispatcher, method) + return JSON.parse(lazyjson, method_dispatcher[method]) end - return JSON3.read(msg_str, Dict{Symbol,Any}) + return JSON.parse(lazyjson, Dict{Symbol,Any}) end # TODO Parse response message? - return JSON3.read(msg_str, Dict{Symbol,Any}) + return JSON.parse(lazyjson, Dict{Symbol,Any}) end writelsp(io::IO, @nospecialize msg) = write_transport_layer(io, to_lsp_json(msg)) @@ -141,7 +137,7 @@ function write_transport_layer(io::IO, response::String) return n end -to_lsp_json(@nospecialize msg) = JSON3.write(msg) +to_lsp_json(@nospecialize msg) = JSON.json(msg; omit_null=true) function Base.close(endpoint::Endpoint) put!(endpoint.out_msg_queue, nothing) # send a special token to terminate the write task diff --git a/LSP/src/precompile.jl b/LSP/src/precompile.jl index d509333d3..1e4008d82 100644 --- a/LSP/src/precompile.jl +++ b/LSP/src/precompile.jl @@ -1,8 +1,8 @@ function test_roundtrip(f, s::AbstractString, Typ) - x = JSON3.read(s, Typ) + x = JSON.parse(s, Typ) f(x) s′ = to_lsp_json(x) - x′ = JSON3.read(s′, Typ) + x′ = JSON.parse(s′, Typ) f(x′) end diff --git a/LSP/test/runtests.jl b/LSP/test/runtests.jl index 2ca8d29c7..d3e921b52 100644 --- a/LSP/test/runtests.jl +++ b/LSP/test/runtests.jl @@ -13,7 +13,7 @@ using Test "result": null }""", DefinitionResponse) do res @test res isa DefinitionResponse - @test_broken res.result === null # this null should be preserved through the roundtrip + @test res.result === null # this null should be preserved through the roundtrip end test_roundtrip("""{ "jsonrpc": "2.0", diff --git a/src/testrunner/testrunner.jl b/src/testrunner/testrunner.jl index 2696e8d13..d6bcc1233 100644 --- a/src/testrunner/testrunner.jl +++ b/src/testrunner/testrunner.jl @@ -538,7 +538,7 @@ function _testrunner_run_testset( end try - LSP.JSON3.read(testrunnerproc, TestRunnerResult) + LSP.JSON.parse(testrunnerproc, TestRunnerResult) catch err @error "Error from testrunner process" err show_error_message(server, """ @@ -666,7 +666,7 @@ function _testrunner_run_testcase( end try - LSP.JSON3.read(testrunnerproc, TestRunnerResult) + LSP.JSON.parse(testrunnerproc, TestRunnerResult) catch err @error "Error from testrunner process" err show_error_message(server, """