From c342c0d1a8d4ccb3654a9bef9ed74d70faad15b3 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 07:04:11 -0600 Subject: [PATCH 1/5] perf(parse): streamline typed struct parsing Route buffer inputs through the positional parsing core, preserve StructUtils customization, and make escaped keys consistent across equality, hashing, symbols, and diagnostics. Add a bounded precompile workload and permanent typed, untyped, tag, and trim coverage. --- Project.toml | 2 +- src/JSON.jl | 30 ++++- src/lazy.jl | 33 ++++-- src/parse.jl | 49 +++++++- test/escaped_keys.jl | 171 +++++++++++++++++++++++++++ test/inbound_tags.jl | 34 ++++++ test/json_trim_public_entrypoints.jl | 67 ++++++++++- test/runtests.jl | 2 + test/trim/Project.toml | 7 ++ 9 files changed, 374 insertions(+), 21 deletions(-) create mode 100644 test/escaped_keys.jl create mode 100644 test/inbound_tags.jl diff --git a/Project.toml b/Project.toml index 8ac8fcd..2a63118 100644 --- a/Project.toml +++ b/Project.toml @@ -22,7 +22,7 @@ Arrow = "2.8.0" ArrowTypes = "2.2" Parsers = "1, 2" PrecompileTools = "1" -StructUtils = "2.8" +StructUtils = "2.8.3" julia = "1.9" [extras] diff --git a/src/JSON.jl b/src/JSON.jl index 6811d12..3764a6f 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -40,7 +40,14 @@ end @enum Error InvalidJSON UnexpectedEOF ExpectedOpeningObjectChar ExpectedOpeningQuoteChar ExpectedOpeningArrayChar ExpectedClosingArrayChar ExpectedComma ExpectedColon ExpectedNewline InvalidChar InvalidNumber InvalidUTF16 -@noinline function invalid(error, buf, pos::Int, T) +@generated _typename(::Type{T}) where {T} = QuoteNode(string(T)) + +@noinline invalid(error, buf, pos::Int, ::Type{T}) where {T} = + _invalid(error, buf, pos, _typename(T)) +@noinline invalid(error, buf, pos::Int, typename::String) = + _invalid(error, buf, pos, typename) + +@noinline function _invalid(error, buf, pos::Int, typename::String) # compute which line the error falls on by counting “\n” bytes up to pos cus = buf isa AbstractString ? codeunits(buf) : buf line_no = count(b -> b == UInt8('\n'), view(cus, 1:pos)) + 1 @@ -64,7 +71,7 @@ end # we call @invoke here to avoid --trim verify errors caret = @invoke(repeat(" "::String, (erri + 2)::Integer)) * "^" msg = """ - invalid JSON at byte position $(pos) (line $line_no) parsing type $T: $error + invalid JSON at byte position $(pos) (line $line_no) parsing type $(typename): $error $snippet$(error == UnexpectedEOF ? " " : "...") $caret """ @@ -140,10 +147,29 @@ print(a, indent=nothing) = print(stdout, a, indent) "See [`json`](@ref)." print +# typed-parse workload struct: exercising one struct with the common field +# shapes caches the shared make/lift/array-chain inference in this package's +# image, so downstream typed parses hit those caches instead of re-inferring +# (which also keeps `juliac --trim` edge inference precise on nested families) +struct _WorkloadInner + x::Int + y::Float64 +end +struct _Workload + item::Union{Nothing,_WorkloadInner} + items::Vector{_WorkloadInner} + tags::Vector{String} + note::Union{Nothing,String} +end + @compile_workload begin x = JSON.parse("{\"a\": 1, \"b\": null, \"c\": true, \"d\": false, \"e\": \"\", \"f\": [1,null,true], \"g\": {\"key\": \"value\"}}") json = JSON.json(x) isvalidjson(json) + JSON.parse( + "{\"item\":{\"x\":1,\"y\":2.0},\"items\":[{\"x\":3,\"y\":4.0}],\"tags\":[\"p\"],\"note\":null}", + _Workload, + ) end diff --git a/src/lazy.jl b/src/lazy.jl index e90a59d..e122699 100644 --- a/src/lazy.jl +++ b/src/lazy.jl @@ -182,14 +182,21 @@ Selectors.@selectors LazyValues Base.lastindex(x::LazyValues) = length(x) # this ensures LazyValues can be "sources" in StructUtils.make -@inline function StructUtils.applyeach(::StructUtils.StructStyle, f, x::LazyValues) +function StructUtils.applyeach(::StructUtils.StructStyle, f, x::LazyValues) type = gettype(x) if type == JSONTypes.OBJECT return applyobject(f, x) elseif type == JSONTypes.ARRAY return applyarray(f, x) end - throw(ArgumentError("applyeach not applicable for `$(typeof(x))` with JSON type = `$type`")) + typename = get(JSONTypes.names, type, "UNKNOWN") + throw(ArgumentError(string( + "applyeach not applicable for `", + _typename(typeof(x)), + "` with JSON type = `JSONTypes.", + typename, + '`', + ))) end @inline function Base.foreach(f, x::LazyValues) @@ -254,7 +261,7 @@ end # core JSON object parsing function # takes a `keyvalfunc` that is applied to each key/value pair -# `keyvalfunc` is provided a PtrString => LazyValue pair +# `keyvalfunc` receives a PtrString => LazyValue pair # `keyvalfunc` can return `StructUtils.EarlyReturn` to short-circuit parsing # otherwise, it should return a `pos::Int` value that notes the next position to continue parsing # to materialize the key, call `convert(String, key)` @@ -445,7 +452,9 @@ function Base.convert(::Type{String}, x::PtrString) return unsafe_string(x.ptr, x.len) end -Base.convert(::Type{Symbol}, x::PtrString) = ccall(:jl_symbol_n, Ref{Symbol}, (Ptr{UInt8}, Int), x.ptr, x.len) +Base.convert(::Type{Symbol}, x::PtrString) = x.escaped ? + Symbol(convert(String, x)) : + ccall(:jl_symbol_n, Ref{Symbol}, (Ptr{UInt8}, Int), x.ptr, x.len) function Base.convert(::Type{T}, x::PtrString) where {T <: Enum} sym = convert(Symbol, x) @@ -455,17 +464,27 @@ function Base.convert(::Type{T}, x::PtrString) where {T <: Enum} throw(ArgumentError("invalid `$T` string value: \"$sym\"")) end -Base.:(==)(x::PtrString, y::AbstractString) = x.len == sizeof(y) && ccall(:memcmp, Cint, (Ptr{UInt8}, Ptr{UInt8}, Csize_t), x.ptr, pointer(y), x.len) == 0 +Base.:(==)(x::PtrString, y::AbstractString) = x.escaped ? + convert(String, x) == y : + x.len == sizeof(y) && ccall(:memcmp, Cint, (Ptr{UInt8}, Ptr{UInt8}, Csize_t), x.ptr, pointer(y), x.len) == 0 Base.:(==)(x::AbstractString, y::PtrString) = y == x -Base.:(==)(x::PtrString, y::PtrString) = x.len == y.len && ccall(:memcmp, Cint, (Ptr{UInt8}, Ptr{UInt8}, Csize_t), x.ptr, y.ptr, x.len) == 0 +Base.:(==)(x::PtrString, y::PtrString) = x.escaped || y.escaped ? + convert(String, x) == convert(String, y) : + x.len == y.len && ccall(:memcmp, Cint, (Ptr{UInt8}, Ptr{UInt8}, Csize_t), x.ptr, y.ptr, x.len) == 0 Base.isequal(x::PtrString, y::AbstractString) = x == y Base.isequal(x::AbstractString, y::PtrString) = y == x Base.isequal(x::PtrString, y::PtrString) = x == y -Base.hash(x::PtrString, h::UInt) = hash(unsafe_string(x.ptr, x.len), h) +Base.hash(x::PtrString, h::UInt) = x.escaped ? + hash(convert(String, x), h) : + hash(unsafe_string(x.ptr, x.len), h) StructUtils.keyeq(x::PtrString, y::AbstractString) = x == y StructUtils.keyeq(x::PtrString, y::String) = x == y StructUtils.keyeq(x::PtrString, y::Symbol) = convert(Symbol, x) == y +# JSON owns PtrString and its comparison semantics, so it can use the ordered +# field cursor without changing StructUtils' behavior for arbitrary key types. +@inline StructUtils.orderedfieldmatch(x::PtrString, field::String) = x == field + # core JSON string parsing function # returns a PtrString and the next position to parse # a PtrString is a semi-lazy, internal-only representation diff --git a/src/parse.jl b/src/parse.jl index 3468ea9..36dba09 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -172,6 +172,7 @@ StructUtils.initialize(::JSONReadStyle, ::Type{Object}, source) = DEFAULT_OBJECT # this allows struct fields to specify tags under the json key specifically to override JSON behavior StructUtils.fieldtagkey(::JSONStyle) = :json StructUtils.defaultstate(st::JSONReadStyle) = StructUtils.defaultstate(st.style) +StructUtils.orderedfields(::JSONReadStyle) = true # forward StructUtils API to the inner style so user-provided JSONStyle dispatches are honored StructUtils.dictlike(st::JSONReadStyle, ::Type{T}) where {T} = StructUtils.dictlike(st.style, T) @@ -188,15 +189,43 @@ function jsonreadstyle(::Type{T}, ::Type{O}, null, style::StructStyle, unknown_f ignore_unknown_fields = unknown_fields === :ignore ? true : unknown_fields === :error ? false : - throw(ArgumentError("`unknown_fields` must be `:ignore` or `:error`, got `$(repr(unknown_fields))`")) + throw(ArgumentError(string( + "`unknown_fields` must be `:ignore` or `:error`, got ", + _unknownoption(unknown_fields), + ))) if T === Any && !ignore_unknown_fields throw(ArgumentError("`unknown_fields` is only supported when parsing into a target type or existing object")) end return JSONReadStyle{O}(null, style, ignore_unknown_fields) end -@noinline unknownfielderror(::Type{T}, key) where {T} = - ArgumentError("encountered unknown JSON member $(repr(key)) while parsing `$T`") +# Use the writer's existing byte escape table. This keeps error text accurate +# without pulling generic `repr` and type-display machinery into trim images. +function _quotedstring(key::AbstractString) + n = escapelength(key) + 2 + buf = Vector{UInt8}(undef, n) + _string(buf, 1, key, nothing, n) + return String(buf) +end + +function _unknownoption(option::Symbol) + value = String(option) + return Base.isidentifier(value) ? string(':', value) : + string("Symbol(", _quotedstring(value), ')') +end +_unknownkey(key::PtrString) = _quotedstring(convert(String, key)) +_unknownkey(key::AbstractString) = _quotedstring(key) +_unknownkey(key::Symbol) = _unknownoption(key) +_unknownkey(key::Integer) = string(key) +_unknownkey(key) = "" + +@noinline unknownfielderror(::Type{T}, key) where {T} = ArgumentError(string( + "encountered unknown JSON member ", + _unknownkey(key), + " while parsing `", + _typename(T), + "`", +)) function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T} st.ignore_unknown_fields || throw(unknownfielderror(T, key)) @@ -217,20 +246,28 @@ parse(io::Union{IO,Base.AbstractCmd}, ::Type{T}=Any; kw...) where {T} = parse(Ba parse!(io::Union{IO,Base.AbstractCmd}, x::T; kw...) where {T} = parse!(Base.read(io), x; kw...) +# No forced @inline through the entry chain: inlining the typed descent into +# these forwarding bodies makes each entry's compilation unit re-optimize the +# entire per-type parse tower instead of calling the already-compiled +# instances (measured at +12s on the first parse of a 35-field Union-typed +# struct, and 30-50% of the first parse of a 13-type struct family). The buffer +# entry therefore calls the positional core directly. Typed customization for +# buffer inputs stays at the StructUtils style/make/lift boundary. A parse +# method specialized on LazyValue applies when the caller passes one directly. parse(buf::Union{AbstractVector{UInt8},AbstractString}, ::Type{T}=Any; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(), unknown_fields::Symbol=:ignore, kw...) where {T,O} = - @inline parse(lazy(buf; kw...), T; dicttype, null, style, unknown_fields) + _parse(lazy(buf; kw...), T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) parse!(buf::Union{AbstractVector{UInt8},AbstractString}, x::T; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(), unknown_fields::Symbol=:ignore, kw...) where {T,O} = - @inline parse!(lazy(buf; kw...), x; dicttype, null, style, unknown_fields) + StructUtils.make!(jsonreadstyle(typeof(x), O, null, style, unknown_fields), x, lazy(buf; kw...)) parse(x::LazyValue, ::Type{T}=Any; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(), unknown_fields::Symbol=:ignore) where {T,O} = - @inline _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) + _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} y, pos = StructUtils.make(style, T, x) diff --git a/test/escaped_keys.jl b/test/escaped_keys.jl new file mode 100644 index 0000000..3e70cd0 --- /dev/null +++ b/test/escaped_keys.jl @@ -0,0 +1,171 @@ +using JSON, Test + +struct EscapedPlainKey + alpha::Int +end + +struct EscapedUnicodeKey + café::Int +end + +JSON.StructUtils.@tags struct EscapedQuoteTag + value::Int & (name="display\"name",) +end + +JSON.StructUtils.@tags struct EscapedAliasTag + value::Int & (name=("alias", "slash\\key"),) +end + +JSON.StructUtils.@tags struct EscapedSymbolTag + value::Int & (name=:alpha,) +end + +JSON.StructUtils.@defaults struct EscapedAliasCollision + a::Int = -1 & (json=(name="b",),) + b::Int = -2 +end + +@enum EscapedEnumKey alpha + +struct EscapedOrderedKeys + a::Int + b::Int + c::Int +end + +struct LazyDispatchProbe end +JSON.parse(::JSON.LazyValue, ::Type{LazyDispatchProbe}; kw...) = :lazy_dispatch + +module EscapedErrorScope +struct Box{T} + value::T +end +end + +function capture_error(f) + try + f() + return nothing + catch err + return err + end +end + +@testset "escaped object keys" begin + @test JSON.parse("{\"\\u0061lpha\":11}", EscapedPlainKey) == EscapedPlainKey(11) + @test JSON.parse("{\"caf\\u00e9\":12}", EscapedUnicodeKey) == EscapedUnicodeKey(12) + @test JSON.parse("{\"display\\\"name\":13}", EscapedQuoteTag) == EscapedQuoteTag(13) + @test JSON.parse("{\"slash\\\\key\":14}", EscapedAliasTag) == EscapedAliasTag(14) + @test JSON.parse("{\"\\u0061lpha\":15}", EscapedSymbolTag) == EscapedSymbolTag(15) + @test JSON.parse("{\"b\":19}", EscapedAliasCollision) == + EscapedAliasCollision(19, -2) + @test JSON.parse("{\"c\":3,\"\\u0061\":1,\"b\":2}", EscapedOrderedKeys) == + EscapedOrderedKeys(1, 2, 3) + @test JSON.parse("{\"\\u0061lpha\":15}", EscapedPlainKey; unknown_fields=:error) == + EscapedPlainKey(15) + + key = Ref{Any}() + source = JSON.lazy("{\"\\u0061lpha\":1}") + GC.@preserve source begin + JSON.applyobject(source) do k, _ + key[] = k + end + end + @test key[] == "alpha" + @test isequal(key[], "alpha") + @test hash(key[]) == hash("alpha") + @test convert(Symbol, key[]) === :alpha + + plain_key = Ref{Any}() + escaped_key = Ref{Any}() + plain_source = JSON.lazy("{\"alpha\":1}") + escaped_source = JSON.lazy("{\"\\u0061lpha\":1}") + GC.@preserve plain_source escaped_source begin + JSON.applyobject(plain_source) do k, _ + plain_key[] = k + end + JSON.applyobject(escaped_source) do k, _ + escaped_key[] = k + end + @test plain_key[] == escaped_key[] + @test isequal(plain_key[], escaped_key[]) + @test hash(plain_key[]) == hash(escaped_key[]) + keys = Dict{Any,Int}(plain_key[] => 1, escaped_key[] => 2) + @test length(keys) == 1 + @test keys[plain_key[]] == 2 + end + + @test JSON.parse("{\"\\u0061lpha\":16}") == Dict("alpha" => 16) + @test JSON.parse("{\"\\u0061lpha\":17}", Dict{Symbol,Int}) == Dict(:alpha => 17) + @test JSON.parse("{\"\\u0061lpha\":18}", Dict{EscapedEnumKey,Int}) == + Dict(alpha => 18) + + for input in ( + "{\"alpha\":1,\"\\u0061lpha\":2}", + "{\"café\":1,\"caf\\u00e9\":2}", + "{\"display\\\"name\":1,\"display\\u0022name\":2}", + "{\"slash\\\\key\":1,\"slash\\u005ckey\":2}", + ) + @test_throws JSON.DuplicateKeyError JSON.parse(input; duplicate_keys=:error) + @test_throws JSON.DuplicateKeyError JSON.parse( + input, + Dict{String,Int}; + duplicate_keys=:error, + ) + end + + err = capture_error() do + JSON.parse("{\"bog\\u0075s\":1}", EscapedPlainKey; unknown_fields=:error) + end + @test err isa ArgumentError + @test occursin("unknown JSON member \"bogus\"", sprint(showerror, err)) + + @testset "error rendering" begin + for (input, rendered) in ( + ("{\"a\\\"b\":1}", "\"a\\\"b\""), + ("{\"a\\\\b\":1}", "\"a\\\\b\""), + ("{\"line\\nbreak\":1}", "\"line\\nbreak\""), + ) + escaped = capture_error() do + JSON.parse(input, EscapedPlainKey; unknown_fields=:error) + end + @test escaped isa ArgumentError + @test occursin("unknown JSON member $rendered", sprint(showerror, escaped)) + end + + option = capture_error() do + JSON.parse("{}", EscapedPlainKey; unknown_fields=Symbol("bad\n\"")) + end + @test option isa ArgumentError + option_message = sprint(showerror, option) + @test occursin("Symbol(\"bad\\n\\\"\")", option_message) + @test !occursin('\n', option_message) + + simple_option = capture_error() do + JSON.parse("{}", EscapedPlainKey; unknown_fields=:boom) + end + @test simple_option isa ArgumentError + @test occursin("got :boom", sprint(showerror, simple_option)) + + symbol_key = JSON.unknownfielderror(EscapedPlainKey, :boom) + @test occursin("unknown JSON member :boom", sprint(showerror, symbol_key)) + + for T in (Vector{Int}, EscapedErrorScope.Box{Int}, Union{Float64,Int}) + typed = capture_error() do + JSON.invalid(JSON.InvalidChar, "x", 1, T) + end + @test typed isa ArgumentError + @test occursin("parsing type $(string(T))", sprint(showerror, typed)) + end + + scalar = capture_error() do + JSON.parse("1", EscapedPlainKey) + end + @test scalar isa ArgumentError + scalar_message = sprint(showerror, scalar) + @test occursin(string(typeof(JSON.lazy("1"))), scalar_message) + @test occursin("JSONTypes.NUMBER", scalar_message) + end + + @test JSON.parse(JSON.lazy("{}"), LazyDispatchProbe) === :lazy_dispatch +end diff --git a/test/inbound_tags.jl b/test/inbound_tags.jl new file mode 100644 index 0000000..7ebcd35 --- /dev/null +++ b/test/inbound_tags.jl @@ -0,0 +1,34 @@ +using JSON, Test + +JSON.StructUtils.@defaults struct JSONIgnoredField + id::Int = 1 + secret::Int = 99 &(json=(ignore=true,),) +end + +JSON.StructUtils.@noarg mutable struct JSONMutableIgnoredField + id::Int = 1 + secret::Int = 99 &(json=(ignore=true,),) +end + +@testset "JSON inbound field tags" begin + @test JSON.parse( + "{\"id\":2,\"secret\":200}", + JSONIgnoredField; + unknown_fields=:error, + ) == JSONIgnoredField(2, 99) + @test_throws ArgumentError JSON.parse( + "{\"id\":2,\"extra\":200}", + JSONIgnoredField; + unknown_fields=:error, + ) + + value = JSONMutableIgnoredField() + value.secret = 55 + JSON.parse!( + "{\"id\":2,\"secret\":200}", + value; + unknown_fields=:error, + ) + @test value.id == 2 + @test value.secret == 55 +end diff --git a/test/json_trim_public_entrypoints.jl b/test/json_trim_public_entrypoints.jl index 61022fd..20762a4 100644 --- a/test/json_trim_public_entrypoints.jl +++ b/test/json_trim_public_entrypoints.jl @@ -10,6 +10,27 @@ end JSON.lower(x::TrimCode) = x.value +struct TrimLeaf + id::Int + name::String +end + +struct TrimRoot + item::Union{Nothing,TrimLeaf} + items::Vector{TrimLeaf} + tags::Vector{String} + note::Union{Nothing,String} +end + +JSON.StructUtils.@defaults struct TrimTagged + value::Int = 0 & (json=(name="wire",),) + secret::Int = 9 & (json=(ignore=true,),) +end + +JSON.StructUtils.@noarg mutable struct TrimMutable + value::Int = 0 +end + function checked(cond::Bool, msg::String)::Nothing cond || error(msg) return nothing @@ -22,11 +43,47 @@ function exercise_lazy_entrypoints()::Nothing end function exercise_parse_entrypoints()::Nothing - # Materializing JSON.parse currently pulls in verifier failures in parser - # and StructUtils error paths, so keep this read workload to trim-safe - # lazy entrypoints until those verifier issues can be chased down. - checked(JSON.lazy("7") isa JSON.LazyValue, "numeric lazy detection failed") - checked(JSON.lazy(IOBuffer(STRING_JSON)) isa JSON.LazyValue, "IO lazy detection failed") + # Open-ended untyped results are data-dependent. Narrow their runtime shape + # before use in a safe-trim binary; `lazy` remains the arbitrary-shape path. + untyped = JSON.parse("{\"name\":\"Ada\"}") + checked(untyped isa JSON.Object{String,Any}, "untyped object parse failed") + name = (untyped::JSON.Object{String,Any})["name"] + checked(name isa String, "untyped string shape failed") + checked((name::String) == "Ada", "untyped string value failed") + + checked(JSON.parse("7", Int) == 7, "typed scalar parse failed") + checked(JSON.parse(IOBuffer(STRING_JSON), String) == "Ada", "typed IO parse failed") + checked(JSON.parse(ARRAY_JSON, Vector{Int}) == [1, 2, 3], "typed array parse failed") + checked(JSON.parse("{\"score\":7}", Dict{String,Int}) == Dict("score" => 7), + "typed dictionary parse failed") + + root = JSON.parse( + "{\"item\":{\"id\":1,\"name\":\"one\"},\"items\":[{\"id\":2,\"name\":\"two\"}],\"tags\":[\"a\"],\"note\":null}", + TrimRoot, + ) + checked(root.item !== nothing && root.item.id == 1, "nested struct parse failed") + checked(length(root.items) == 1 && root.items[1].name == "two", + "nested vector parse failed") + checked(root.tags == ["a"] && root.note === nothing, "nullable field parse failed") + + tagged = JSON.parse( + "{\"wire\":4,\"secret\":99}", + TrimTagged; + unknown_fields=:error, + ) + checked(tagged == TrimTagged(4, 9), "field-tag parse failed") + + mutable_value = TrimMutable() + JSON.parse!("{\"value\":8}", mutable_value; unknown_fields=:error) + checked(mutable_value.value == 8, "parse! failed") + + unknown = try + JSON.parse("{\"extra\":1}", TrimLeaf; unknown_fields=:error) + nothing + catch err + err + end + checked(unknown isa ArgumentError, "unknown-field diagnostic failed") return nothing end diff --git a/test/runtests.jl b/test/runtests.jl index 2c33ca9..f70ddac 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,6 +3,8 @@ using JSON, Test, Tar include(joinpath(dirname(pathof(JSON)), "../test/object.jl")) include(joinpath(dirname(pathof(JSON)), "../test/lazy.jl")) include(joinpath(dirname(pathof(JSON)), "../test/parse.jl")) +include(joinpath(dirname(pathof(JSON)), "../test/escaped_keys.jl")) +include(joinpath(dirname(pathof(JSON)), "../test/inbound_tags.jl")) include(joinpath(dirname(pathof(JSON)), "../test/json.jl")) # Arrow.jl is broken on 32 bit systems for now :( if Sys.WORD_SIZE == 64 diff --git a/test/trim/Project.toml b/test/trim/Project.toml index bdf83a4..cf627fa 100644 --- a/test/trim/Project.toml +++ b/test/trim/Project.toml @@ -1,2 +1,9 @@ [deps] JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2" +Parsers = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" + +# Parsers #207 removes inference barriers that leave widened Float64 parsing +# unreachable in safe-trim binaries. The fix is merged after v2.8.6. Remove +# this source pin after the next Parsers release contains commit 24a05a9. +[sources] +Parsers = {url = "https://github.com/JuliaData/Parsers.jl", rev = "24a05a9979e9d7ba0b4665803b73a81cd0316ea8"} From 4f8a1302b25977fc4f9745a3bc7aaebcd1c373ff Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 10:25:08 -0600 Subject: [PATCH 2/5] ci(test): pin companion StructUtils branch JSON requires StructUtils 2.8.3 before that release exists in General. Add the exact StructUtils PR head to test and documentation environments, and document the removal trigger. --- .github/workflows/CI.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 494961d..e776077 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,6 +37,16 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 + # StructUtils 2.8.3 is the companion PR and is not registered yet. + # Remove this step after JuliaServices/StructUtils.jl#65 is released. + - name: Use companion StructUtils PR + shell: julia --color=yes --project=@. {0} + run: | + using Pkg + Pkg.add(PackageSpec( + url = "https://github.com/JuliaServices/StructUtils.jl", + rev = "97c8c474c4496c4f59ef366199a64ca742e2dfab", + )) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 @@ -51,6 +61,19 @@ jobs: contents: write steps: - uses: actions/checkout@v7 + - uses: julia-actions/setup-julia@v3 + with: + version: '1' + # StructUtils 2.8.3 is the companion PR and is not registered yet. + # Remove this step after JuliaServices/StructUtils.jl#65 is released. + - name: Use companion StructUtils PR + shell: julia --color=yes --project=@. {0} + run: | + using Pkg + Pkg.add(PackageSpec( + url = "https://github.com/JuliaServices/StructUtils.jl", + rev = "97c8c474c4496c4f59ef366199a64ca742e2dfab", + )) - uses: julia-actions/julia-buildpkg@latest - uses: julia-actions/julia-docdeploy@latest env: From 78900b15ba9a8f522ec2300644f035b1d750792d Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 10:32:27 -0600 Subject: [PATCH 3/5] test(parse): cover unknown-key diagnostics Exercise integer and opaque unknown-key rendering through the diagnostic API. This covers the safe fallback branches without weakening Codecov's project threshold. --- test/escaped_keys.jl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/escaped_keys.jl b/test/escaped_keys.jl index 3e70cd0..0e97a38 100644 --- a/test/escaped_keys.jl +++ b/test/escaped_keys.jl @@ -150,6 +150,12 @@ end symbol_key = JSON.unknownfielderror(EscapedPlainKey, :boom) @test occursin("unknown JSON member :boom", sprint(showerror, symbol_key)) + integer_key = JSON.unknownfielderror(EscapedPlainKey, 7) + @test occursin("unknown JSON member 7", sprint(showerror, integer_key)) + + fallback_key = JSON.unknownfielderror(EscapedPlainKey, (value=1,)) + @test occursin("unknown JSON member ", sprint(showerror, fallback_key)) + for T in (Vector{Int}, EscapedErrorScope.Box{Int}, Union{Float64,Int}) typed = capture_error() do JSON.invalid(JSON.InvalidChar, "x", 1, T) From 8f448095f29d9ee67099a5bad73bfafee48b0c52 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 11:42:55 -0600 Subject: [PATCH 4/5] ci(test): use registered StructUtils release --- .github/workflows/CI.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e776077..494961d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,16 +37,6 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 - # StructUtils 2.8.3 is the companion PR and is not registered yet. - # Remove this step after JuliaServices/StructUtils.jl#65 is released. - - name: Use companion StructUtils PR - shell: julia --color=yes --project=@. {0} - run: | - using Pkg - Pkg.add(PackageSpec( - url = "https://github.com/JuliaServices/StructUtils.jl", - rev = "97c8c474c4496c4f59ef366199a64ca742e2dfab", - )) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 @@ -61,19 +51,6 @@ jobs: contents: write steps: - uses: actions/checkout@v7 - - uses: julia-actions/setup-julia@v3 - with: - version: '1' - # StructUtils 2.8.3 is the companion PR and is not registered yet. - # Remove this step after JuliaServices/StructUtils.jl#65 is released. - - name: Use companion StructUtils PR - shell: julia --color=yes --project=@. {0} - run: | - using Pkg - Pkg.add(PackageSpec( - url = "https://github.com/JuliaServices/StructUtils.jl", - rev = "97c8c474c4496c4f59ef366199a64ca742e2dfab", - )) - uses: julia-actions/julia-buildpkg@latest - uses: julia-actions/julia-docdeploy@latest env: From edc4da06539575c6bfad34e89bade5037dad89d1 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 15:39:48 -0600 Subject: [PATCH 5/5] test(trim): use registered Parsers release --- test/trim/Project.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/trim/Project.toml b/test/trim/Project.toml index cf627fa..70d591c 100644 --- a/test/trim/Project.toml +++ b/test/trim/Project.toml @@ -1,9 +1,3 @@ [deps] JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2" Parsers = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" - -# Parsers #207 removes inference barriers that leave widened Float64 parsing -# unreachable in safe-trim binaries. The fix is merged after v2.8.6. Remove -# this source pin after the next Parsers release contains commit 24a05a9. -[sources] -Parsers = {url = "https://github.com/JuliaData/Parsers.jl", rev = "24a05a9979e9d7ba0b4665803b73a81cd0316ea8"}