Skip to content

Commit a718c6c

Browse files
committed
comm: complete migration to JSON.jl v1
This commit completes the migration to JSON.jl v1. As explained in #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, JSON function readlsp_JSON_v1(msg_str::AbstractString) lazyjson = JSON.lazy(msg_str) if hasproperty(lazyjson, :method) method = lazyjson.method[] if method isa String && haskey(LSP.method_dispatcher, method) return JSON.parse(lazyjson, LSP.method_dispatcher[method]) end return JSON.parse(lazyjson, Dict{Symbol,Any}) else # TODO parse to ResponseMessage? return JSON.parse(lazyjson, Dict{Symbol,Any}) end end ``` ```julia using JSON3, LSP const Parsed = @NamedTuple{method::Union{Nothing,String}} function readlsp_JSON3(msg_str::AbstractString) parsed = JSON3.read(msg_str, Parsed) parsed_method = parsed.method if parsed_method !== nothing if haskey(LSP.method_dispatcher, parsed_method) return JSON3.read(msg_str, LSP.method_dispatcher[parsed_method]) end return JSON3.read(msg_str, Dict{Symbol,Any}) else # TODO parse to ResponseMessage? return JSON3.read(msg_str, Dict{Symbol,Any}) end end ``` ```julia 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" } }"""; ``` ```julia-repl julia> @benchmark readlsp_JSON3(s) BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample. Range (min … max): 363.792 μs … 1.154 ms ┊ GC (min … max): 0.00% … 0.00% Time (median): 379.958 μs ┊ GC (median): 0.00% Time (mean ± σ): 385.114 μs ± 16.805 μs ┊ GC (mean ± σ): 0.00% ± 0.00% ▁▁ ▁ ▄▇█▇▆▆▅▅▅▅▄▄▄▄▃▃▃▂▂▂▂▂▁▁ ▁ ▁ ▂ ██████████████████████████████████████▇▇▇▇▇▇▇▇▆▅▆▆▆▆▅▆▇▆▅▆▆▅ █ 364 μs Histogram: log(frequency) by time 447 μs < Memory estimate: 5.91 KiB, allocs estimate: 120. julia> @benchmark readlsp_JSON_v1(s) BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample. Range (min … max): 11.625 μs … 113.000 μs ┊ GC (min … max): 0.00% … 0.00% Time (median): 12.375 μs ┊ GC (median): 0.00% Time (mean ± σ): 12.658 μs ± 1.933 μs ┊ GC (mean ± σ): 0.00% ± 0.00% ▃▄▅▅▆██▇█▅▅▄▃▂▂ ▂ ▇██████████████████▇▇▆▇▆▅▇▅▅▆▆▆▇▆▆▇▇▆▇▆▆▇▇▅▇██▆▇▅▅▆▆▆▅▅▄▄▂▅▆ █ 11.6 μs Histogram: log(frequency) by time 17.4 μs < Memory estimate: 4.53 KiB, allocs estimate: 84. ``` --- - renamed `LSP/src/utils` to `LSP/src/DSL` - also added precompilation statements for better FTTJ - added docstrings
1 parent eeb2e1e commit a718c6c

File tree

15 files changed

+760
-239
lines changed

15 files changed

+760
-239
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ Manifest-*.toml
66
**/.JETLSConfig.toml
77
*.vsix
88
docs/build/
9+
LocalPreference.toml

JSONRPC/src/JSONRPC.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function readmsg(io::IO, method_dispatcher)
6363
lazyjson = JSON.lazy(msg_str)
6464
if hasproperty(lazyjson, :method)
6565
method = lazyjson.method[]
66-
if haskey(method_dispatcher, method)
66+
if method isa String && haskey(method_dispatcher, method)
6767
return JSON.parse(lazyjson, method_dispatcher[method])
6868
end
6969
return JSON.parse(lazyjson, Dict{Symbol,Any})

LSP/Project.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ projects = ["test"]
88

99
[deps]
1010
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
11+
PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
12+
Preferences = "21216c6a-2e73-6563-6e65-726566657250"
1113
StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
1214

1315
[compat]
1416
JSON = "1.3"
17+
PrecompileTools = "1.3.3"
18+
Preferences = "1.5.0"
1519
StructUtils = "2.6.0"

LSP/src/DSL/interface.jl

Lines changed: 429 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,45 @@
1+
"""
2+
@namespace NamespaceName::Type begin
3+
CONSTANT_NAME = value
4+
...
5+
end
6+
7+
Creates a Julia module containing typed constants that correspond to TypeScript `namespace`
8+
definitions from the LSP specification.
9+
10+
# Type references
11+
12+
Due to the design that mimics TypeScript `namespaces` using Julia's module system,
13+
namespace types must be referenced with the `.Ty` suffix:
14+
15+
```julia
16+
@namespace SignatureHelpTriggerKind::Int begin
17+
Invoked = 1
18+
TriggerCharacter = 2
19+
ContentChange = 3
20+
end
21+
22+
@interface SignatureHelpContext begin
23+
...
24+
# Use as type annotation
25+
triggerKind::SignatureHelpTriggerKind.Ty
26+
...
27+
end
28+
```
29+
30+
This is a constraint of Julia's module scoping rules, where constants and type aliases
31+
within modules cannot be accessed without explicit qualification.
32+
33+
# Implementation details
34+
35+
The macro generates:
36+
1. A Julia module with the specified namespace name
37+
2. Constants with the specified type and values
38+
3. A `Ty` type alias equal to the specified type for convenient type references
39+
4. Automatic export of the namespace name to the parent module
40+
41+
See also: [`@interface`](@ref)
42+
"""
143
macro namespace(exs...)
244
nexs = length(exs)
345
nexs == 2 || error("`@namespace` expected 2 arguments: ", exs)

LSP/src/LSP.jl

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,33 @@ module LSP
33
using StructUtils: StructUtils
44
using JSON: JSON
55

6+
using Preferences: Preferences
7+
const LSP_DEV_MODE = Preferences.@load_preference("LSP_DEV_MODE", false)
8+
69
include("URIs2/URIs2.jl")
710
using ..URIs2: URI
811

912
const exports = Set{Symbol}()
1013
const method_dispatcher = Dict{String,DataType}()
1114

12-
include("utils/interface.jl")
13-
include("utils/namespace.jl")
15+
# NOTE `Null` and `URI` are referenced directly from interface.jl, so it should be defined before that.
1416

15-
include("base-protocol.jl")
17+
"""
18+
A special object representing `null` value.
19+
When used as a field that might be omitted in the serialized JSON (i.e. the field can be `nothing`),
20+
the key-value pair appears as `null` instead of being omitted.
21+
This special object is specifically intended for use in `ResponseMessage`.
22+
"""
23+
StructUtils.@nonstruct struct Null end
24+
const null = Null()
25+
Base.show(io::IO, ::Null) = print(io, "null")
26+
StructUtils.lower(::Null) = JSON.Null()
27+
push!(exports, :Null, :null)
28+
29+
include("DSL/interface.jl")
30+
include("DSL/namespace.jl")
1631

32+
include("base-protocol.jl")
1733
include("basic-json-structures.jl")
1834
include("lifecycle-messages/register-capability.jl")
1935
include("lifecycle-messages/unregister-capability.jl")
@@ -49,4 +65,6 @@ end
4965
export
5066
method_dispatcher
5167

68+
include("precompile.jl")
69+
5270
end # module LSP

LSP/src/base-protocol.jl

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
# Base types
22
# =========
33

4-
"""
5-
A special object representing `null` value.
6-
When used as a field that might be omitted in the serialized JSON (i.e. the field can be `nothing`),
7-
the key-value pair appears as `null` instead of being omitted.
8-
This special object is specifically intended for use in `ResponseMessage`.
9-
"""
10-
struct Null end
11-
const null = Null()
12-
StructUtils.lower(::Null) = JSON.Null()
13-
push!(exports, :Null, :null)
14-
154
const boolean = Bool
165
const string = String
176

LSP/src/language-features/completions.jl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ end
234234
export CompletionData
235235

236236
@interface CompletionItem begin
237-
238237
"""
239238
The label of this completion item.
240239

LSP/src/language-features/definition.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ end
2828

2929
@interface DefinitionResponse @extends ResponseMessage begin
3030
result::Union{Location, Vector{Location}, Vector{LocationLink}, Null, Nothing}
31-
end
31+
end

LSP/src/precompile.jl

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
function readlsp(msg_str::AbstractString)
2+
lazyjson = JSON.lazy(msg_str)
3+
if hasproperty(lazyjson, :method)
4+
method = lazyjson.method[]
5+
if haskey(method_dispatcher, method)
6+
return JSON.parse(lazyjson, method_dispatcher[method])
7+
end
8+
return JSON.parse(lazyjson, Dict{Symbol,Any})
9+
else # TODO parse to ResponseMessage?
10+
return JSON.parse(lazyjson, Dict{Symbol,Any})
11+
end
12+
end
13+
writelsp(x) = JSON.json(x; omit_null=true)
14+
15+
function test_roundtrip(f, s::AbstractString, Typ)
16+
x = JSON.parse(s, Typ)
17+
f(x)
18+
s′ = writelsp(x)
19+
x′ = JSON.parse(s′, Typ)
20+
f(x′)
21+
end
22+
23+
using PrecompileTools
24+
25+
@setup_workload let
26+
uri = LSP.URIs2.filepath2uri(abspath(@__FILE__))
27+
@compile_workload let
28+
test_roundtrip("""{
29+
"jsonrpc": "2.0",
30+
"id":0, "method":"textDocument/completion",
31+
"params": {
32+
"textDocument": {
33+
"uri": "$uri"
34+
},
35+
"position": {
36+
"line": 0,
37+
"character": 0
38+
},
39+
"workDoneToken": "workDoneToken",
40+
"partialResultToken": "partialResultToken"
41+
}
42+
}""", CompletionRequest) do req
43+
@assert req isa CompletionRequest
44+
end
45+
test_roundtrip("""{
46+
"jsonrpc": "2.0",
47+
"id": 0,
48+
"method": "completionItem/resolve",
49+
"params": {
50+
"label": "label",
51+
"textEdit": {
52+
"range": {
53+
"start": {
54+
"line": 0,
55+
"character": 0
56+
},
57+
"end": {
58+
"line": 0,
59+
"character": 0
60+
}
61+
},
62+
"newText": "newText"
63+
}
64+
}
65+
}""", CompletionResolveRequest) do req
66+
@assert req isa CompletionResolveRequest
67+
@assert req.params.textEdit isa TextEdit
68+
end
69+
end
70+
end

0 commit comments

Comments
 (0)