diff --git a/src/parse.jl b/src/parse.jl index 3468ea9..7f8a264 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -188,15 +188,24 @@ 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))`")) + # plain symbol interpolation (not `repr`): `show(::Symbol)` routes through the + # `@nospecialize`d Expr-show machinery, which drags hundreds of unresolvable + # dynamic calls into `--trim` builds from this error path alone + throw(ArgumentError("`unknown_fields` must be `:ignore` or `:error`, got `:$(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 +# explicit key formatting (not `repr` or plain interpolation): keys arrive as +# `PtrString`, which has no `print`/`show` of its own, so both routes fall back to the +# generic struct `show` — an unresolvable dynamic site under `--trim`. Keys can also be +# `Int` (array-into-struct with extra elements) or other scalars, hence the fallback. +unknownfieldkey(key::PtrString) = convert(String, key) +unknownfieldkey(key) = string(key) @noinline unknownfielderror(::Type{T}, key) where {T} = - ArgumentError("encountered unknown JSON member $(repr(key)) while parsing `$T`") + ArgumentError("encountered unknown JSON member \"$(unknownfieldkey(key))\" while parsing `$T`") function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T} st.ignore_unknown_fields || throw(unknownfielderror(T, key)) @@ -372,7 +381,9 @@ 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")) + # don't interpolate the lazy value: its `show` pulls the array-display machinery + # into `--trim` builds from this (unreachable in practice) fallback branch + throw(ArgumentError("cannot parse json")) end 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/json_trim_workload.jl b/test/json_trim_workload.jl new file mode 100644 index 0000000..05467cb --- /dev/null +++ b/test/json_trim_workload.jl @@ -0,0 +1,137 @@ +# Trim-compile workload: covers typed and untyped parse, JSON writing, and — +# specifically for the trim-verifier fixes — the parse error paths (unknown +# fields with PtrString keys, the bad `unknown_fields` option message) and the +# hand-formatted Date/DateTime/Time lowers. Compiled by +# test/trim_compile_tests.jl with `juliac --trim=safe` (error budget zero), +# then executed so the assertions also prove runtime behavior. +using JSON +using Dates + +struct TrimUser + id::Int + name::String +end + +@defaults struct TrimConfig + enabled::Bool = false + label::String = "none" + count::Int = 0 +end + +function _assert_typed_parse()::Nothing + u = JSON.parse("""{"id":1,"name":"ab"}""", TrimUser) + u.id == 1 || error("typed id") + u.name == "ab" || error("typed name") + + c = JSON.parse("""{"enabled":true,"count":7}""", TrimConfig) + c.enabled || error("kwdef bool") + c.label == "none" || error("kwdef default") + c.count == 7 || error("kwdef count") + + v = JSON.parse("""[1,2,3]""", Vector{Int}) + v == [1, 2, 3] || error("typed vector") + + d = JSON.parse("""{"a":1,"b":2}""", Dict{String,Int}) + d["a"] == 1 || error("typed dict") + return nothing +end + +# the error paths the trim fixes rewrote: these must not only compile under +# --trim but produce the exact messages at runtime +function _assert_error_paths()::Nothing + threw = false + try + JSON.parse("""{"id":1,"name":"a","extra":2}""", TrimUser; unknown_fields = :error) + catch e + e isa ArgumentError || error("unknown-field error type") + m = e.msg + m isa String || error("unknown-field msg type") + # PtrString key formatting (unknownfieldkey) + occursin("unknown JSON member \"extra\"", m) || error("unknown-field msg") + occursin("TrimUser", m) || error("unknown-field target type") + threw = true + end + threw || error("unknown-field did not throw") + + threw = false + try + JSON.parse("""{"id":1,"name":"a"}""", TrimUser; unknown_fields = :bogus) + catch e + e isa ArgumentError || error("bad-option error type") + m = e.msg + m isa String || error("bad-option msg type") + # plain symbol interpolation (not `repr`/Expr-show) + occursin(":bogus", m) || error("bad-option msg") + threw = true + end + threw || error("bad-option did not throw") + return nothing +end + +function _assert_untyped_parse()::Nothing + # untyped parse returns Any by design (any JSON value type) — narrow the + # top-level to the concrete Object before indexing, as trim-compiled + # consumers must + raw = JSON.parse("""{"a":[1,2],"b":{"c":null},"s":"x","f":1.5,"t":true}""") + raw isa JSON.Object{String,Any} || error("untyped root type") + o = raw + a = o["a"] + a isa Vector{Any} || error("untyped array type") + length(a) == 2 || error("untyped array len") + a1 = a[1] + (a1 isa Int && a1 == 1) || error("untyped array elem") + rawb = o["b"] + rawb isa JSON.Object{String,Any} || error("untyped nested type") + c = rawb["c"] + c === nothing || error("untyped null") + s = o["s"] + (s isa String && s == "x") || error("untyped string") + f = o["f"] + (f isa Float64 && f == 1.5) || error("untyped float") + t = o["t"] + (t === true) || error("untyped bool") + return nothing +end + +function _assert_json_write()::Nothing + JSON.json((; a = [1, 2], b = "x")) == "{\"a\":[1,2],\"b\":\"x\"}" || error("json nt") + JSON.json([1, 2, 3]) == "[1,2,3]" || error("json vector") + JSON.json(Dict("k" => 1)) == "{\"k\":1}" || error("json dict") + JSON.json("q\"uote") == "\"q\\\"uote\"" || error("json escape") + JSON.json(nothing) == "null" || error("json null") + u = TrimUser(3, "n") + JSON.json(u) == "{\"id\":3,\"name\":\"n\"}" || error("json struct") + round = JSON.parse(JSON.json(u), TrimUser) + round.id == 3 || error("roundtrip") + return nothing +end + +# hand-formatted ISO lowers: exact renderings, including millisecond padding +# and the ms-free forms +function _assert_date_lowers()::Nothing + JSON.json(Date(2026, 7, 3)) == "\"2026-07-03\"" || error("date lower") + JSON.json(DateTime(2026, 7, 3, 4, 5, 6)) == "\"2026-07-03T04:05:06\"" || error("datetime lower") + JSON.json(DateTime(2026, 7, 3, 4, 5, 6, 70)) == "\"2026-07-03T04:05:06.070\"" || error("datetime ms pad") + JSON.json(Time(1, 2, 3)) == "\"01:02:03\"" || error("time lower") + JSON.json(Time(1, 2, 3, 45)) == "\"01:02:03.045\"" || error("time ms pad") + JSON.json((; d = Date(2026, 1, 2), t = Time(23, 59, 59))) == + "{\"d\":\"2026-01-02\",\"t\":\"23:59:59\"}" || error("dates in object") + return nothing +end + +function run_json_trim_workload()::Nothing + _assert_typed_parse() + _assert_error_paths() + _assert_untyped_parse() + _assert_json_write() + _assert_date_lowers() + return nothing +end + +function @main(args::Vector{String})::Cint + _ = args + run_json_trim_workload() + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/runtests.jl b/test/runtests.jl index ca1498a..3891ea4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -105,3 +105,5 @@ end ] @test JSON.json(x, 2) isa String end + +include(joinpath(dirname(pathof(JSON)), "../test/trim_compile_tests.jl")) diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl new file mode 100644 index 0000000..a567c01 --- /dev/null +++ b/test/trim_compile_tests.jl @@ -0,0 +1,184 @@ +using Test + +const _TRIM_SAFE_ERROR_BUDGET = 0 +const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" +const _TRIM_PRE_RELEASE = !isempty(VERSION.prerelease) +const _TRIM_COMPILE_TIMEOUT_S = Sys.iswindows() ? 600.0 : 120.0 +const _TRIM_EXECUTABLE_TIMEOUT_S = Sys.iswindows() ? 120.0 : 30.0 +const _TRIM_USE_BUNDLE = Sys.iswindows() +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") + return setenv(cmd, env) +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 + # JSON from the local checkout and adds JuliaC. + pkg_path = normpath(joinpath(@__DIR__, "..")) + env_path = mktempdir() + julia = joinpath(Sys.BINDIR, Base.julia_exename()) + setup_script = joinpath(env_path, "setup.jl") + write(setup_script, """ + import Pkg + Pkg.develop(path=$(repr(pkg_path))) + Pkg.add("JuliaC") + # 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 + # the trim verify set, and the registered release still fails verification. + Pkg.add(url="https://github.com/JuliaData/Parsers.jl", rev="trim-verifier-fixes") + """) + 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 = _TRIM_COMPILE_TIMEOUT_S, 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 _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 trim_errors > 0 || trim_warnings > 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_cmd = `$(abspath(run_path))` + run_exit, run_output, run_timed_out = _run_command_with_timeout(run_cmd; timeout_s = _TRIM_EXECUTABLE_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_workloads = [ + ("json_trim_workload.jl", "json_trim_workload"), + ] + for (script_file, output_name) in trim_workloads + _run_trim_case(project_path, script_file, output_name) + end + end +end