diff --git a/Manifest-v1.12.toml b/Manifest-v1.12.toml new file mode 100644 index 0000000..979f7fe --- /dev/null +++ b/Manifest-v1.12.toml @@ -0,0 +1,88 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.12.3" +manifest_format = "2.0" +project_hash = "19f7a579e8486201e771cd906646aaa821873ea9" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.JSON]] +deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] +path = "." +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "1.6.1" + + [deps.JSON.extensions] + JSONArrowExt = ["ArrowTypes"] + + [deps.JSON.weakdeps] + ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.6" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "edbeefc7a4889f528644251bdb5fc9ab5348bc2c" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.3.4" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.StructUtils]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" +uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" +version = "2.8.2" + + [deps.StructUtils.extensions] + StructUtilsMeasurementsExt = ["Measurements"] + StructUtilsStaticArraysCoreExt = ["StaticArraysCore"] + StructUtilsTablesExt = ["Tables"] + + [deps.StructUtils.weakdeps] + Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" + StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" + Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" diff --git a/src/JSON.jl b/src/JSON.jl index c4c2a44..a048d60 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -25,7 +25,7 @@ end @enum Error InvalidJSON UnexpectedEOF ExpectedOpeningObjectChar ExpectedOpeningQuoteChar ExpectedOpeningArrayChar ExpectedClosingArrayChar ExpectedComma ExpectedColon ExpectedNewline InvalidChar InvalidNumber InvalidUTF16 -@noinline function invalid(error, buf, pos::Int, T) +@noinline function invalid(error, buf, pos::Int, @nospecialize(T)) # 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 @@ -48,8 +48,13 @@ end snippet = replace(snippet, r"[\b\f\n\r\t]" => " ") # we call @invoke here to avoid --trim verify errors caret = @invoke(repeat(" "::String, (erri + 2)::Integer)) * "^" + # the type renders via an isa ladder with per-branch typeasserts: + # interpolating the type object infers show machinery per target type (a + # large per-type TTFX tax) and is dynamic under --trim now that this + # helper is @nospecialize + tname = _typenamestr(T) 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 $(tname): $error $snippet$(error == UnexpectedEOF ? " " : "...") $caret """ @@ -87,6 +92,7 @@ end include("lazy.jl") include("parse.jl") +include("tier0.jl") include("write.jl") """ @@ -125,10 +131,28 @@ print(a, indent=nothing) = print(stdout, a, indent) "See [`json`](@ref)." print +function __init__() + # :hot-annotated struct definitions in downstream packages fire this hook + # during their precompilation, compiling the typed parse/write paths for + # each annotated type into that package's image + StructUtils.register_hot_hook!(_hot_json_hook) + return nothing +end + +# exercises the untyped engine plus the tier-0 typed default (engine → field +# table interpreter), so a downstream user's first typed parse of any +# eligible struct costs a table build instead of compiling a descent +@kwarg struct _EngineWorkload + a::Int = 0 + b::Union{String,Nothing} = nothing + c::Vector{Float64} = Float64[] +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("{\"a\": 1, \"b\": \"x\", \"c\": [1.5], \"z\": null}", _EngineWorkload) end diff --git a/src/parse.jl b/src/parse.jl index 3468ea9..104ca2e 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -151,6 +151,11 @@ import StructUtils: StructStyle abstract type JSONStyle <: StructStyle end +# lazy values carry their own lift/descent protocol; the tier-0 interpreter +# must never drive them directly (the fused engine in tier0.jl is the +# interpreter-shaped path for lazy input) +StructUtils.interpsource(::LazyValues) = false + # defining a custom style allows us to pass a non-default dicttype `O` through JSON.parse, # while still delegating custom behavior to an inner StructStyle if one was provided struct JSONReadStyle{O,T,S} <: JSONStyle @@ -184,19 +189,41 @@ StructUtils.structlike(st::JSONReadStyle{O,N,S}, ::Type{T}) where {O,N,S<:JSONSt StructUtils.structlike(st::JSONReadStyle{O,N,S}, ::Type{T}) where {O,N,S<:JSONStyle,T<:NamedTuple} = StructUtils.structlike(st.style, T) -function jsonreadstyle(::Type{T}, ::Type{O}, null, style::StructStyle, unknown_fields::Symbol) where {T,O} +function jsonreadstyle(@nospecialize(T), ::Type{O}, null, style::StructStyle, unknown_fields::Symbol) where {O} 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 `:", String(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`") +# isa-laddered string extraction: no per-target-type inference of show +# machinery (the 1.5.1 TTFX regression class), and no dynamic repr-of-Any +# for the trim verifier to reject +@noinline function unknownfielderror(@nospecialize(T), @nospecialize(key)) + # per-branch typeasserts: narrowing on @nospecialize arguments doesn't + # stick, and the verifier needs each call fully concrete + keystr = key isa String ? (key::String) : + key isa PtrString ? convert(String, key::PtrString) : + key isa Symbol ? String(key::Symbol) : + key isa Int ? string(key::Int) : "" + return ArgumentError(string("encountered unknown JSON member \"", keystr::String, + "\" while parsing `", _typenamestr(T), "`")) +end + +function _typenamestr(@nospecialize(T)) + # unwrap UnionAlls by hand: Base's nameof(::UnionAll) recurses through an + # Any-typed body, which the trim verifier can't resolve + body = T + while body isa UnionAll + body = getfield(body::UnionAll, :body) + end + body isa DataType && return String(nameof(body::DataType)::Symbol) + return "" +end function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T} st.ignore_unknown_fields || throw(unknownfielderror(T, key)) @@ -232,12 +259,37 @@ parse(x::LazyValue, ::Type{T}=Any; unknown_fields::Symbol=:ignore) where {T,O} = @inline _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} + if StructUtils.TRIM_BUILD + # trim binaries need the static call graph + y, pos = StructUtils.make(style, T, x) + getisroot(x) && checkendpos(x, T, pos) + return y + end + if !StructUtils.ishot(T) && style isa _FUSED_STYLE + # the default: one lazy pass drives the field-table interpreter — + # any target type, and a new type costs a table build, not a compile + y, pos = _fused_make(style, T, x) + getisroot(x) && checkendpos(x, T, pos) + return y::T + end + # invokelatest keeps the per-type descent out of this function's + # compilation: a plain call here would specialize the whole descent for + # every parsed type, including the ones the engine above handles. Types + # that route here (`:hot`, custom styles/dicttype/null) pay one dynamic + # dispatch per parse. + return Base.invokelatest(_parse_specialized, x, T, style) +end + +function _parse_specialized(x::LazyValue, ::Type{T}, style::StructStyle) where {T} y, pos = StructUtils.make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y end + + mutable struct ValueClosure value::Any ValueClosure() = new() @@ -260,7 +312,7 @@ parse!(x::LazyValue, obj::T; # for LazyValue, if x started at the beginning of the JSON input, # then we want to ensure that the entire input was consumed # and error if there are any trailing invalid JSON characters -function checkendpos(x::LazyValue, ::Type{T}, pos::Int) where {T} +function checkendpos(x::LazyValue, @nospecialize(T), pos::Int) buf = getbuf(x) len = getlength(buf) if pos <= len @@ -372,7 +424,10 @@ function StructUtils.make(st::StructStyle, ::Type{Any}, x::LazyValues) elseif type == JSONTypes.TRUE || type == JSONTypes.FALSE return StructUtils.lift(st, Bool, x) else - throw(ArgumentError("cannot parse $x")) + # no value interpolation: `show(::LazyValue)` materializes and prints + # a LazyArray through Base's matrix-show machinery — a large dynamic + # fan-out under --trim; `invalid` renders position + type statically + invalid(InvalidJSON, getbuf(x), getpos(x), Any) end end @@ -502,6 +557,9 @@ function StructUtils.lift(style::JSONReadStyle, ::Type{T}, x::LazyValues, tags=( end end +# JSONText fields must reach this make method rather than being field-parsed +StructUtils.custommake(::Type{JSONText}) = true + function StructUtils.make(::StructStyle, ::Type{JSONText}, x::LazyValues) buf = getbuf(x) pos = getpos(x) diff --git a/src/tier0.jl b/src/tier0.jl new file mode 100644 index 0000000..af53c95 --- /dev/null +++ b/src/tier0.jl @@ -0,0 +1,375 @@ +# The default typed-parse engine: one pass over the lazy JSON drives the +# StructUtils field-table interpreter directly — no intermediate tree. The +# closures below are parameterized by style only, never by the target type, +# so this whole file compiles once (during JSON's own precompilation, via +# the workload) and a user's first typed parse of any struct costs a table +# build instead of a compile. +# +# It handles every target type: struct objects and element vectors stream +# straight off the lazy tokens; a field whose type has its own lift/make/ +# choosetype hooks receives the raw lazy value, so user hooks see exactly +# what they'd see on the per-type path; everything else (dict/tuple/set/ +# multidim-array fields, mismatched shapes) reads its subtree into plain +# Julia values and lets the interpreter's generic handlers finish. +# +# Trimmed binaries skip this file's route entirely (its dispatch decisions +# happen at runtime, which a trimmed binary can't compile for) and use the +# per-type path, whose call targets are all static. +# +# Also here: the precompile hook JSON registers with StructUtils — when a +# downstream package defines a `:hot` struct, this hook parses synthesized +# samples during THAT package's precompilation so the type's specialized +# parse/write code lands in its image. + +# Only the default read configuration uses the engine. A custom dicttype or +# null changes what values materialize as, and a custom style can carry +# per-style lazy `lift` methods or trait overrides that the engine would +# silently skip (it reads scalars into plain values before lifting, and +# classifies types structurally) — so those all take the per-type path, +# where every user hook is dispatched normally. +const _FUSED_STYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} + +# ---------------- fused tier-0 lazy interpretation ---------------- + +struct FusedObjClosure{S<:StructStyle} + style::S + tbl::StructUtils.FieldTable + slots::Vector{Any} +end + +function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} + specs = f.tbl.specs + i = 0 + for j = 1:length(specs) + if k == @inbounds(specs[j]).name # PtrString == String: no allocation + i = j + break + end + end + if i == 0 + # miss on declared names: alias tuples and raw name tags register + # extra candidates (only consulted when the table declares any) + for j = 1:length(specs) + if @inbounds(specs[j]).aliases !== nothing + i = StructUtils.findspec(specs, convert(String, k)) + break + end + end + end + if i == 0 + # unknown key: honor the style hook (unknown_fields=:error throws); + # returning non-Int makes applyobject skip the value unmaterialized + StructUtils.unknownfield(f.style, f.tbl.T, k, v) + return nothing + end + return _fused_fillslot!(f.style, f.slots, i, @inbounds(specs[i]), v) +end + +# fill slot i for a matched spec (by key or position); returns the next +# lazy position (or nothing to let the applier skip unmaterialized) +function _fused_fillslot!(style::StructStyle, slots::Vector{Any}, i::Int, + sp::StructUtils.FieldSpec, v::LazyValue) + vs = sp.spec + if gettype(v) == JSONTypes.NULL + # an explicit null fills a field that admits both Missing and + # Nothing with `missing` (matching how make resolves such unions) + if vs.missingable + slots[i] = missing + elseif vs.nullable + slots[i] = nothing + elseif vs.kind == StructUtils.KIND_ANY + slots[i] = nothing + elseif vs.kind == StructUtils.KIND_CUSTOM + val, _ = StructUtils.make(style, vs.declft::Type, nothing, sp.tags) + slots[i] = val + else + x, _ = StructUtils.lift(style, vs.ft::Type, nothing) + slots[i] = x + end + return getpos(v) + 4 + end + val, pos = _fused_field(style, sp, v) + slots[i] = val + return pos +end + +# structs can also fill from a JSON array: elements map to fields in +# declaration order, and surplus elements go to the style's unknownfield +# hook (ignored by default) +struct FusedPosClosure{S<:StructStyle} + style::S + tbl::StructUtils.FieldTable + slots::Vector{Any} +end + +function (f::FusedPosClosure{S})(i::Int, v::LazyValue) where {S} + specs = f.tbl.specs + if i > length(specs) + StructUtils.unknownfield(f.style, f.tbl.T, i, v) + return nothing + end + return _fused_fillslot!(f.style, f.slots, i, @inbounds(specs[i]), v) +end + +# field-level: CUSTOM carries the field's tags; everything else goes through +# the spec tree +function _fused_field(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) + vs = sp.spec + if vs.kind == StructUtils.KIND_CUSTOM + # custom-kind fields (user lift/make targets, choosetype tags, + # abstract declared types) receive the RAW lazy value — user hooks + # see exactly what the specialized descent hands them + val, st = StructUtils.make(style, vs.declft::Type, v, sp.tags) + return val, st isa Int ? st : skip(v) + end + return _fused_spec(style, vs, v, sp.name) +end + +# applyarray closure appending each parsed element to the field vector +struct FusedArrClosure{S<:StructStyle} + style::S + el::StructUtils.ValueSpec + arr::Any + name::String +end + +function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} + el = f.el + local val, pos + if gettype(v) == JSONTypes.NULL + # null element: Missing arm first, as in the object closure above + if el.missingable + val, pos = missing, getpos(v) + 4 + elseif el.nullable + val, pos = nothing, getpos(v) + 4 + else + x, _ = StructUtils.lift(f.style, el.ft::Type, nothing) + val, pos = x, getpos(v) + 4 + end + else + val, pos = _fused_spec(f.style, el, v, f.name) + end + push!(f.arr::Vector, val) + return pos +end + +# position-level recursion mirroring StructUtils.makevalue, driven lazily. +# Struct objects and element vectors — the overwhelmingly common shapes — +# drive the lazy tokens directly; custom kinds hand the RAW lazy value to +# the generic machinery; every other (kind, shape) pairing materializes its +# subtree into plain Julia values and hands them to the interpreter's +# generic handlers — every shape covered, still no per-type compile. +function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) + k = vs.kind + if k == StructUtils.KIND_STRUCT + t = gettype(v) + if t == JSONTypes.OBJECT || t == JSONTypes.ARRAY + tbl = StructUtils.fieldtable(vs.ft::DataType, style) + if tbl.eligible + t == JSONTypes.OBJECT && return _fused_struct(style, tbl, v) + return _fused_struct_positional(style, tbl, v) + end + end + elseif k == StructUtils.KIND_VECTOR + if gettype(v) == JSONTypes.ARRAY + el = vs.child::StructUtils.ValueSpec + arr = StructUtils.allocvector(el.declft, 0) + f = FusedArrClosure{typeof(style)}(style, el, arr, name) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return arr, pos + end + elseif k == StructUtils.KIND_UNION2 + arm = gettype(v) == JSONTypes.ARRAY ? (vs.child::StructUtils.ValueSpec) : + (vs.child2::StructUtils.ValueSpec) + return _fused_spec(style, arm, v, name) + elseif k == StructUtils.KIND_ANY + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return out.value, pos + elseif k == StructUtils.KIND_CUSTOM + # raw lazy value to the generic machinery — user lift/make/choosetype + # hooks see exactly what the specialized descent hands them + val, st = StructUtils.make(style, vs.declft::Type, v) + return val, st isa Int ? st : skip(v) + elseif !(k == StructUtils.KIND_DICT || k == StructUtils.KIND_TUPLE || + k == StructUtils.KIND_FIXEDARRAY || k == StructUtils.KIND_SETLIKE || + k == StructUtils.KIND_UNSUPPORTED) + # scalar leaf kinds parse straight off the lazy token + return _fused_scalar(style, vs, v, name) + end + # dict/tuple/set/fixed-array kinds, unsupported leaves, and shape + # mismatches (struct-from-array, vector-from-object): materialize the + # subtree into plain values; the interpreter's generic handlers cover + # any shape + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return StructUtils.makevalue(style, vs, out.value, name), pos +end + +# scalar leaves: parse the base JSON scalar lazily, then produce the +# exact-typed value through the interpreter's per-kind conversions (ISO +# dates, integer widths, symbols, chars, with a generic lift fallback for +# odd pairings) +function _fused_scalar(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) + kind = vs.kind + ft = vs.ft + t = gettype(v) + if t == JSONTypes.STRING + buf = getbuf(v) + local s, pos + GC.@preserve buf begin + str, pos = parsestring(v) + s = convert(String, str) + end + return StructUtils.liftleaf(style, kind, ft, s, name), pos + elseif t == JSONTypes.NUMBER + num, pos = parsenumber(v) + raw = isint(num) ? num.int : + isfloat(num) ? num.float : + isbigint(num) ? num.bigint : num.bigfloat + return StructUtils.liftleaf(style, kind, ft, raw, name), pos + elseif t == JSONTypes.TRUE + return StructUtils.liftleaf(style, kind, ft, true, name), getpos(v) + 4 + elseif t == JSONTypes.FALSE + return StructUtils.liftleaf(style, kind, ft, false, name), getpos(v) + 5 + else + # aggregate token into a scalar-kind field: materialize the subtree; + # the interpreter's generic handlers (lift fallback included) decide + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return StructUtils.makevalue(style, vs, out.value, name), pos + end +end + +# the object↔struct fast path: one lazy pass drives the field-table slots. +# @noinline: the boundary between per-type entry glue and the compile-once +# engine — inlined, the JIT re-infers the engine per target type +@noinline function _fused_struct(style::StructStyle, tbl::StructUtils.FieldTable, v::LazyValue) + slots = Vector{Any}(undef, length(tbl.specs)) + f = FusedObjClosure{typeof(style)}(style, tbl, slots) + pos = applyobject(f, v) + pos isa Int || (pos = skip(v)) + return StructUtils.construct(style, tbl, slots, v), pos +end + +# struct-from-array: fill the slot buffer positionally +function _fused_struct_positional(style::StructStyle, tbl::StructUtils.FieldTable, v::LazyValue) + slots = Vector{Any}(undef, length(tbl.specs)) + f = FusedPosClosure{typeof(style)}(style, tbl, slots) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return StructUtils.construct(style, tbl, slots, v), pos +end + +# root entry for ANY target type: the spec tree describes T (built once per +# (target, style type)); custom/unsupported roots hand the raw lazy value to +# the generic machinery — the never-error backstop +@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) + vs = StructUtils.rootspec(T, style) + if vs.kind == StructUtils.KIND_CUSTOM || vs.kind == StructUtils.KIND_UNSUPPORTED || + vs.kind == StructUtils.KIND_FIXEDARRAY + # custom/unsupported shapes and fixed-size arrays (0-dim included: + # dimension discovery needs the raw lazy value) take the generic route + val, st = StructUtils.make(style, T::Type, v) + return val, st isa Int ? st : skip(v) + end + if gettype(v) == JSONTypes.NULL + return StructUtils.makevalue(style, vs, nothing, "root"), getpos(v) + 4 + end + return _fused_spec(style, vs, v, "root") +end + +# ---------------- :hot precompile hook + sample synthesis ---------------- + +# registered with StructUtils from __init__: called for each :hot-annotated +# struct during the *defining package's* precompilation, inside a newly- +# inferred-tagging block — everything parsed here (the typed lazy descent, +# the write path) lands in that package's image +function _hot_json_hook(@nospecialize(T), samples::Tuple) + T isa Type || return nothing + for s in samples + s isa AbstractString || continue + try + x = parse(String(s), T) + json(x) + catch + end + end + s = try + _synthesize_sample(T) + catch + nothing + end + if s !== nothing + try + x = parse(s, T) + json(x) + catch + end + end + try + parse("{}", T) + catch + end + return nothing +end + +# build a minimal valid JSON sample for T from its field table: dummy leaf +# per kind, recursion for nested structs/vectors; CUSTOM-kind fields are +# omitted (defaults/nullability cover them, and "{}" is the fallback) +function _synthesize_sample(@nospecialize(T)) + style = JSONReadStyle{DEFAULT_OBJECT_TYPE}(nothing) + tbl = StructUtils.fieldtable(T, style) + tbl.eligible || return nothing + io = IOBuffer() + Base.print(io, '{') + isfirst = true + for sp in tbl.specs + frag = _synth_value(sp.spec) + frag === nothing && continue + isfirst || Base.print(io, ',') + isfirst = false + Base.print(io, '"', sp.name, "\":", frag) + end + Base.print(io, '}') + return String(take!(io)) +end + +# a minimal JSON fragment satisfying one field spec, or nothing to omit it +function _synth_value(vs::StructUtils.ValueSpec) + SU = StructUtils + kind = vs.kind + if kind == SU.KIND_STRING || kind == SU.KIND_SYMBOL + return "\"s\"" + elseif kind == SU.KIND_CHAR + return "\"c\"" + elseif SU.KIND_INT64 <= kind <= SU.KIND_UINT128 + return "1" + elseif SU.KIND_FLOAT64 <= kind <= SU.KIND_FLOAT16 + return "1.5" + elseif kind == SU.KIND_BOOL + return "true" + elseif kind == SU.KIND_DATE + return "\"2020-01-02\"" + elseif kind == SU.KIND_DATETIME + return "\"2020-01-02T03:04:05\"" + elseif kind == SU.KIND_TIME + return "\"03:04:05\"" + elseif kind == SU.KIND_UUID + return "\"c8b1cf79-de6a-54ab-a142-682c06a0de6a\"" + elseif kind == SU.KIND_ANY + return "1" + elseif kind == SU.KIND_STRUCT + return _synthesize_sample(vs.ft) + elseif kind == SU.KIND_VECTOR + el = _synth_value(vs.child::StructUtils.ValueSpec) + return el === nothing ? nothing : string('[', el, ']') + elseif kind == SU.KIND_DICT + el = _synth_value(vs.child::StructUtils.ValueSpec) + return el === nothing ? nothing : string("{\"k\":", el, '}') + elseif kind == SU.KIND_UNION2 + return _synth_value(vs.child2::StructUtils.ValueSpec) # the scalar arm + end + return nothing +end diff --git a/src/write.jl b/src/write.jl index 5262175..374f938 100644 --- a/src/write.jl +++ b/src/write.jl @@ -34,6 +34,31 @@ const StringLike = Union{Enum, AbstractChar, VersionNumber, Cstring, Cwstring, U StructUtils.lower(::JSONStyle, ::Missing) = nothing StructUtils.lower(::JSONStyle, x::Symbol) = String(x) StructUtils.lower(::JSONStyle, x::StringLike) = string(x) +# trim-friendly ISO renderings: string(::TimeType) routes through Dates' +# DateFormat machinery (dynamic lpad/repeat) under juliac --trim +StructUtils.lower(::JSONStyle, x::Dates.Date) = _iso_date_string(x) +StructUtils.lower(::JSONStyle, x::Dates.DateTime) = _iso_datetime_string(x) +StructUtils.lower(::JSONStyle, x::Dates.Time) = _iso_time_string(x) + +_two_dig(x::Int)::String = x < 10 ? string('0', x) : string(x) +_three_dig(x::Int)::String = x < 10 ? string("00", x) : x < 100 ? string('0', x) : string(x) +_four_dig(x::Int)::String = x < 10 ? string("000", x) : x < 100 ? string("00", x) : x < 1000 ? string('0', x) : string(x) + +_iso_date_string(d::Dates.Date)::String = + string(_four_dig(Dates.year(d)), '-', _two_dig(Dates.month(d)), '-', _two_dig(Dates.day(d))) + +function _iso_datetime_string(dt::Dates.DateTime)::String + ms = Dates.millisecond(dt) + base = string(_iso_date_string(Dates.Date(dt)), 'T', + _two_dig(Dates.hour(dt)), ':', _two_dig(Dates.minute(dt)), ':', _two_dig(Dates.second(dt))) + return ms == 0 ? base : string(base, '.', _three_dig(ms)) +end + +function _iso_time_string(t::Dates.Time)::String + ms = Dates.millisecond(t) + base = string(_two_dig(Dates.hour(t)), ':', _two_dig(Dates.minute(t)), ':', _two_dig(Dates.second(t))) + return ms == 0 ? base : string(base, '.', _three_dig(ms)) +end StructUtils.lower(::JSONStyle, x::Regex) = x.pattern StructUtils.lower(::JSONStyle, x::Complex) = (re=real(x), im=imag(x)) StructUtils.lower(::JSONStyle, x::AbstractArray{<:Any,0}) = x[1] diff --git a/test/hot_lazy_trim_safe.jl b/test/hot_lazy_trim_safe.jl new file mode 100644 index 0000000..5a91fa7 --- /dev/null +++ b/test/hot_lazy_trim_safe.jl @@ -0,0 +1,89 @@ +# :hot typed parsing under `juliac --trim=safe`: the specialized lazy +# descent for annotated types, driven from real JSON text. Unlike +# tree-shaped sources (where the hot findfield's field x value-type +# cross-product limits scalar variety), the lazy source is uniform — every +# field branch sees a LazyValue — so heterogeneous scalars (dates, UUIDs, +# symbols) are exercised here. Compiled in an env with the StructUtils +# `trim_build` preference, which prunes the tier-0 tree route and its +# invokelatest boundary out of the typed-parse entry. +using JSON, StructUtils, Dates, UUIDs + +@kwarg :hot struct LTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg :hot struct LEvent + name::String + day::Date = Date(0) + at::Union{DateTime,Nothing} = nothing + uid::Union{UUID,Nothing} = nothing + cap::Union{Int,Nothing} = nothing + kind::Symbol = :none + venue::Union{LTier,Nothing} = nothing + tiers::Vector{LTier} = LTier[] + score::Union{Float64,Missing} = missing +end + +const SAMPLE = """ +{"name":"Kickoff","day":"2026-08-01","at":"2026-07-25T23:59:59", + "uid":"c8b1cf79-de6a-54ab-a142-682c06a0de6a","cap":64,"kind":"league", + "venue":{"name":"Gym","amount":1}, + "tiers":[{"name":"Early","amount":2500,"currency":"eur"},{"name":"Late"}], + "score":null,"unknown_extra":[1,2,3]} +""" + +function run_hot_lazy_trim_sample() + evx = JSON.parse(SAMPLE, LEvent) + evx isa LEvent || error("type") + e = evx::LEvent + e.name == "Kickoff" || error("name") + e.day == Date(2026, 8, 1) || error("day") + e.at == DateTime(2026, 7, 25, 23, 59, 59) || error("at") + e.uid == UUID("c8b1cf79-de6a-54ab-a142-682c06a0de6a") || error("uid") + e.cap == 64 || error("cap") + e.kind === :league || error("kind") + v = e.venue + v isa LTier || error("venue") + (v::LTier).amount == 1 || error("venue amount") + length(e.tiers) == 2 || error("tiers") + e.tiers[1].currency == "eur" || error("cur1") + e.tiers[2].currency == "usd" || error("cur2") + e.score === missing || error("score") + # defaults-only parse + m = JSON.parse("{\"name\":\"m\"}", LEvent) + (m::LEvent).cap === nothing || error("m cap") + isempty((m::LEvent).tiers) || error("m tiers") + # untyped parse + isa-narrow (the #472 pattern) + u = JSON.parse(SAMPLE) + if u isa JSON.Object{String,Any} + c = u["cap"] + c isa Int64 || error("untyped cap") + c == 64 || error("untyped cap value") + else + error("untyped root") + end + # TODO(write-side trim): JSON.json of a struct is not yet verifier-clean + # on master — the writer's BigFloat arm reaches string(::BigFloat) MPFR + # internals and an array-show typeinfo invoke_in_world. Write tiering is + # follow-up work; this workload pins the read path. + # required-field error path + threw = false + try + JSON.parse("{\"amount\":1}", LTier) + catch + threw = true + end + threw || error("required") + return nothing +end + +function @main(args::Vector{String})::Cint + _ = args + run_hot_lazy_trim_sample() + Core.println("HOT_LAZY_TRIM_OK") + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/interp_default.jl b/test/interp_default.jl new file mode 100644 index 0000000..0061b63 --- /dev/null +++ b/test/interp_default.jl @@ -0,0 +1,106 @@ +using Test, JSON, Dates, UUIDs, StructUtils + +# tier-0 default typed parsing: every non-:hot typed parse under the default +# read configuration drives the StructUtils field-table interpreter in one +# lazy pass; :hot types and custom dicttype/null take the specialized +# descent. These tests pin parity across the routing boundary. + +@kwarg struct IDTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg :hot struct IDHotTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg struct IDEvent + name::String + day::Date = Date(0) + at::Union{DateTime,Nothing} = nothing + uid::Union{UUID,Nothing} = nothing + cap::Union{Int,Nothing} = nothing + kind::Symbol = :none &(json=(name="event_kind",),) + venue::Union{IDTier,Nothing} = nothing + tiers::Vector{IDTier} = IDTier[] + note::Any = nothing + score::Union{Float64,Missing} = missing +end + +@nonstruct struct IDPct + v::Float64 +end +JSON.lift(::Type{IDPct}, x) = IDPct(Float64(x)) + +@kwarg struct IDCustom + p::IDPct = IDPct(0.0) +end + +const IDJSON = """ +{"name":"Kickoff","day":"2026-08-01","at":"2026-07-25T23:59:59", + "uid":"c8b1cf79-de6a-54ab-a142-682c06a0de6a","cap":64,"event_kind":"league", + "venue":{"name":"Gym","amount":1}, + "tiers":[{"name":"Early","amount":2500,"currency":"eur"},{"name":"Late"}], + "note":{"k":"v"},"score":null,"unknown":123} +""" + +@testset "tier-0 default typed parse" begin + ev = JSON.parse(IDJSON, IDEvent) + @test ev.name == "Kickoff" + @test ev.day == Date(2026, 8, 1) + @test ev.at == DateTime(2026, 7, 25, 23, 59, 59) + @test ev.uid == UUID("c8b1cf79-de6a-54ab-a142-682c06a0de6a") + @test ev.cap == 64 + @test ev.kind === :league # :json tagkey rename resolved in the field table + @test ev.venue isa IDTier && ev.venue.amount == 1 && ev.venue.currency == "usd" + @test length(ev.tiers) == 2 + @test ev.tiers[1].currency == "eur" && ev.tiers[2].currency == "usd" + @test ev.note isa JSON.Object{String,Any} && ev.note["k"] == "v" + @test ev.score === missing + + # defaults + fresh containers per parse + a = JSON.parse("{\"name\":\"a\"}", IDEvent) + b = JSON.parse("{\"name\":\"b\"}", IDEvent) + @test isempty(a.tiers) && a.tiers !== b.tiers + @test a.venue === nothing && a.cap === nothing && a.kind === :none + + # unknown_fields=:error preserved through the interpreter route + @test_throws ArgumentError JSON.parse("{\"name\":\"x\",\"nope\":1}", IDTier; unknown_fields=:error) + + # :hot types keep the lazy descent, identical results + @test StructUtils.ishot(IDHotTier) + h = JSON.parse("{\"name\":\"h\",\"amount\":3}", IDHotTier) + @test h.name == "h" && h.amount == 3 && h.currency == "usd" + + # custom lift leaf (CUSTOM kind, dynamic arm) + c = JSON.parse("{\"p\": 0.25}", IDCustom) + @test c.p.v == 0.25 + + # custom dicttype and null take the specialized descent with their + # existing semantics (note: on that path, Any-typed *fields* materialize + # as JSON.Object via the lift fallback regardless of dicttype — same as + # master) + d = JSON.parse(IDJSON, IDEvent; dicttype=Dict{String,Any}) + @test d.note isa JSON.Object{String,Any} && d.note["k"] == "v" + m = JSON.parse("{\"name\":\"x\",\"score\":null}", IDEvent; null=missing) + @test m.score === missing + + # non-object roots drive the interpreter spec tree + @test JSON.parse("[{\"name\":\"t\"}]", Vector{IDTier})[1].name == "t" + + # sample synthesis produces parseable JSON for eligible types + s = JSON._synthesize_sample(IDEvent) + @test s isa String + sev = JSON.parse(s, IDEvent) + @test sev isa IDEvent && sev.venue isa IDTier && length(sev.tiers) == 1 + + # the hot hook is registered with StructUtils + @test any(h -> h === JSON._hot_json_hook, StructUtils.HOT_HOOKS) + # and runs cleanly under force for both annotated and plain types + StructUtils.hot_precompile!(IDHotTier; force=true) + StructUtils.hot_precompile!(IDEvent, ("{\"name\":\"s\"}",); force=true) + @test true +end diff --git a/test/parse.jl b/test/parse.jl index a20980a..1a2e082 100644 --- a/test/parse.jl +++ b/test/parse.jl @@ -726,16 +726,23 @@ JSON.lift(::DateMaterializedObjectStyle, ::Type{Date}, x::JSON.Object) = Date(x[ m = Array{Float64,0}(undef) m[1] = 1.0 @test JSON.parse("1.0", Array{Float64,0}) == m - # test custom JSONStyle - # StructUtils.lift(::CustomJSONStyle, ::Type{UUID}, x) = UUID(UInt128(x)) - # @test JSON.parse("340282366920938463463374607431768211455", UUID; style=CustomJSONStyle()) == UUID(typemax(UInt128)) - # @test JSON.parse("{\"id\": 0, \"uuid\": 340282366920938463463374607431768211455}", N; style=CustomJSONStyle()) == N(0, UUID(typemax(UInt128))) + # test custom JSONStyle: per-style lift + @choosetype route through + # the specialized descent, where user hooks see raw lazy values + StructUtils.lift(::CustomJSONStyle, ::Type{UUID}, x) = UUID(UInt128(x)) + @test JSON.parse("340282366920938463463374607431768211455", UUID; style=CustomJSONStyle()) == UUID(typemax(UInt128)) + @test JSON.parse("{\"id\": 0, \"uuid\": 340282366920938463463374607431768211455}", N; style=CustomJSONStyle()) == N(0, UUID(typemax(UInt128))) # tricky unions @test JSON.parse("{\"id\":0}", O) == O(0, nothing) @test JSON.parse("{\"id\":0,\"name\":null}", O) == O(0, missing) - # StructUtils.choosetype(::CustomJSONStyle, ::Type{Union{I,L,Missing,Nothing}}, val) = JSON.gettype(val) == JSON.JSONTypes.NULL ? Missing : hasproperty(val, :fruit) ? I : L - # @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"name\":\"jim\",\"fruit\":\"apple\"}}", O; style=CustomJSONStyle()) == O(0, I(1, "jim", apple)) - # @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"firstName\":\"jim\",\"rate\":3.14}}", O; style=CustomJSONStyle()) == O(0, L(1, "jim", 3.14)) + # style-scoped make overrides (which @choosetype emits) dispatch on + # the READ WRAPPER carrying the custom style as its third parameter: + # JSON wraps user styles in JSONReadStyle, and only traits/lift are + # forwarded to the inner style + StructUtils.@choosetype JSON.JSONReadStyle{<:Any,<:Any,CustomJSONStyle} Union{I,L,Missing,Nothing} val -> + JSON.gettype(val) == JSON.JSONTypes.NULL ? Missing : + hasproperty(val, :fruit) ? I : L + @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"name\":\"jim\",\"fruit\":\"apple\"}}", O; style=CustomJSONStyle()) == O(0, I(1, "jim", apple)) + @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"firstName\":\"jim\",\"rate\":3.14}}", O; style=CustomJSONStyle()) == O(0, L(1, "jim", 3.14)) StructUtils.liftkey(::JSON.JSONStyle, ::Type{Point}, x::String) = Point(parse(Int, split(x, "_")[1]), parse(Int, split(x, "_")[2])) @test JSON.parse("{\"1_2\":\"hi\"}", Dict{Point, String}) == Dict(Point(1, 2) => "hi") diff --git a/test/runtests.jl b/test/runtests.jl index ca1498a..a62d259 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,6 +3,7 @@ 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/interp_default.jl")) include(joinpath(dirname(pathof(JSON)), "../test/json.jl")) # Arrow.jl is broken on 32 bit systems for now :( if Sys.WORD_SIZE == 64 @@ -105,3 +106,5 @@ end ] @test JSON.json(x, 2) isa String end + +include("trim_compile_tests.jl") diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl new file mode 100644 index 0000000..6f9a9ef --- /dev/null +++ b/test/trim_compile_tests.jl @@ -0,0 +1,202 @@ +using StructUtils +using Test + +const _TRIM_SAFE_ERROR_BUDGET = 0 # JSON typed/untyped parse + write, error budget zero +const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" +const _TRIM_PRE_RELEASE = !isempty(VERSION.prerelease) +const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" + +# Pkg.test() sets JULIA_LOAD_PATH restrictively, which prevents subprocesses +# from finding stdlib packages like Pkg. Remove it so subprocesses get the +# default load path. +function _clean_cmd(cmd::Cmd) + env = Dict{String,String}(k => v for (k, v) in ENV if k != "JULIA_LOAD_PATH") + # juliac sessions must not execute precompile workloads: they would bake + # JIT-only instances into the image for the verifier to reject + env["JULIAC_DISABLE_PRECOMPILE_WORKLOADS"] = "1" + return setenv(cmd, env) +end + +function _trim_use_bundle()::Bool + default = Sys.iswindows() ? "1" : "0" + return get(ENV, "JSON_TRIM_BUNDLE", default) == "1" +end + +function _setup_trim_env() + # JuliaC requires Julia 1.12+ and can't be in [extras] without breaking + # Pkg.test() on older Julia versions. Create a temp project that dev's + # StructUtils from the local checkout and adds JuliaC. + json_path = normpath(joinpath(@__DIR__, "..")) + # use whatever StructUtils this test session resolved: a dev checkout is + # dev'd into the temp env; a registry copy is added normally + su_path = normpath(joinpath(dirname(pathof(StructUtils)), "..")) + su_setup = startswith(su_path, joinpath(homedir(), ".julia", "packages")) ? + "Pkg.add(\"StructUtils\")" : "Pkg.develop(path=$(repr(su_path)))" + env_path = mktempdir() + julia = joinpath(Sys.BINDIR, Base.julia_exename()) + setup_script = joinpath(env_path, "setup.jl") + # TODO: drop once Parsers.jl#207 (trim fixes for the float overflow-widening + # ladder) is released — JSON's number parsing pulls Parsers' float path into + # every workload graph + write(setup_script, """ + import Pkg + $(su_setup) + Pkg.develop(path=$(repr(json_path))) + Pkg.add(url="https://github.com/JuliaData/Parsers.jl", rev="trim-verifier-fixes") + Pkg.add("JuliaC") + """) + println("[trim] setting up temp environment with JuliaC...") + flush(stdout) + exit_code, output, timed_out = _run_command_with_timeout( + _clean_cmd(`$julia --startup-file=no --history-file=no --project=$env_path $setup_script`); + timeout_s = 120.0, log_label = "setup" + ) + rm(setup_script; force = true) + if exit_code != 0 || timed_out + println("[trim] setup FAILED (exit=$exit_code, timed_out=$timed_out)") + println(output) + error("failed to set up trim test environment") + end + println("[trim] temp environment ready") + return env_path +end + +function _run_trim_compile(project_path::String, script_path::String, output_name::String; timeout_s::Float64 = 120.0, bundle_dir::Union{Nothing, String} = nothing) + julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) + cmd = if bundle_dir === nothing + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --project=$project_path --experimental --trim=safe $script_path`) + else + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --bundle $bundle_dir --project=$project_path --experimental --trim=safe $script_path`) + end + return _run_command_with_timeout(cmd; timeout_s = timeout_s, log_label = "compile") +end + +function _run_command_with_timeout(cmd::Cmd; timeout_s::Float64, log_label::String) + output_path = tempname() + out = open(output_path, "w") + exit_code = -1 + timed_out = false + try + proc = run(pipeline(ignorestatus(cmd), stdout = out, stderr = out); wait = false) + timed_out = _wait_process_with_timeout!(proc; timeout_s = timeout_s, log_label = log_label) + exit_code = something(proc.exitcode, -1) + finally + close(out) + end + output = try + read(output_path, String) + catch + "" + finally + rm(output_path; force = true) + end + return exit_code, output, timed_out +end + +function _wait_process_with_timeout!(proc::Base.Process; timeout_s::Float64, log_label::String) + started_at = time() + next_log_at = started_at + 10.0 + timed_out = false + while Base.process_running(proc) + now = time() + if now - started_at >= timeout_s + timed_out = true + try; kill(proc); catch; end + break + end + if now >= next_log_at + elapsed = round(now - started_at; digits = 1) + println("[trim] $(log_label) WAIT $(elapsed)s") + flush(stdout) + next_log_at = now + 10.0 + end + sleep(0.1) + end + try; wait(proc); catch; end + return timed_out +end + +function _parse_trim_verify_totals(output::String) + m = match(r"Trim verify finished with\s+(\d+)\s+errors,\s+(\d+)\s+warnings\.", output) + m === nothing && return nothing + return parse(Int, m.captures[1]), parse(Int, m.captures[2]) +end + +function _trim_executable_timeout_s()::Float64 + default = Sys.iswindows() ? "120.0" : "30.0" + return parse(Float64, get(ENV, "JSON_TRIM_EXE_TIMEOUT_S", default)) +end + +function _run_trim_case(project_path::String, script_file::String, output_name::String) + script_path = joinpath(@__DIR__, script_file) + @test isfile(script_path) + println("[trim] compile START $(script_file)") + start_t = time() + mktempdir() do tmpdir + cd(tmpdir) do + bundle_dir = _trim_use_bundle() ? joinpath(tmpdir, "bundle") : nothing + exit_code, output, timed_out = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) + if timed_out + println("[trim] compile TIMED OUT for $(script_file)") + println(output) + @test false + return + end + totals = _parse_trim_verify_totals(output) + trim_errors, trim_warnings = if totals === nothing + exit_code == 0 ? (0, 0) : error("failed to parse trim verifier summary:\n$output") + else + totals + end + if get(ENV, "JSON_TRIM_PRINT_OUTPUT", "0") == "1" || trim_errors > 0 + println("---- trim compile output ($(script_file)) ----") + println(output) + println("---- end output ----") + end + @test trim_errors <= _TRIM_SAFE_ERROR_BUDGET + @test trim_warnings >= 0 + output_path = Sys.iswindows() ? "$(output_name).exe" : output_name + if trim_errors == 0 + run_path = bundle_dir === nothing ? output_path : joinpath(bundle_dir, "bin", output_path) + @test exit_code == 0 + @test isfile(run_path) + run_timeout_s = _trim_executable_timeout_s() + run_cmd = `$(abspath(run_path))` + run_exit, run_output, run_timed_out = _run_command_with_timeout(run_cmd; timeout_s = run_timeout_s, log_label = "run") + if run_timed_out + println("[trim] executable TIMED OUT for $(script_file)") + println(run_output) + end + if run_exit != 0 + println("---- trim executable output ($(script_file)) ----") + println(run_output) + println("---- end output ----") + end + @test !run_timed_out + @test run_exit == 0 + else + @test exit_code != 0 + end + end + end + println("[trim] compile DONE $(script_file) ($(round(time() - start_t; digits = 2))s)") + return nothing +end + +@testset "Trim compile" begin + if !_TRIM_SUPPORTED + println("[trim] skip Julia < 1.12: JuliaC trim compilation is unavailable") + @test true + elseif _TRIM_PRE_RELEASE + println("[trim] skip prerelease Julia: trim verifier behavior is not stable yet") + @test true + else + project_path = _setup_trim_env() + # trim builds set the trim_build preference: it prunes the fused + # tier-0 route out of the typed-parse entry, leaving the specialized + # descent's static graph + write(joinpath(project_path, "LocalPreferences.toml"), + "[StructUtils]\ntrim_build = true\n") + _run_trim_case(project_path, "hot_lazy_trim_safe.jl", "hot_lazy_trim_safe") + end +end