diff --git a/docs/src/reading.md b/docs/src/reading.md index ae1c5c3..cc9c83d 100644 --- a/docs/src/reading.md +++ b/docs/src/reading.md @@ -202,6 +202,54 @@ json = """ employee = JSON.parse(json, Employee) ``` +### Locating conversion failures + +Pass `error_context=true` to identify the input value that failed while parsing: + +```julia +err = try + JSON.parse("""{"counts":[1,"bad"]}""", @NamedTuple{counts::Vector{Int}}; + error_context=true) +catch err + err +end + +err.path # "/counts/1" +err.position # 14 +err.cause isa MethodError # true +``` + +[`JSON.ParseError`](@ref) keeps the original exception in `cause` and its +backtrace in `backtrace`. Its `path` is an +[RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901): array indices +start at zero, `~` becomes `~0`, and `/` becomes `~1` within an object key. +The empty path `""` identifies the input root. Input names are used even when +field tags rename Julia fields. Duplicate keys can share a pointer; `position` +distinguishes their occurrences. + +The byte position is one-based and identifies the start of the value being +converted, rather than a cursor inside a syntax error. Missing fields and +constructor failures identify the containing object. When parsing a selected +`LazyValue`, the pointer is relative to that selected value, while the position +still refers to its original buffer. JSON Lines inputs use array indices for +their lines. + +This option also works with `parse!`, `parsefile`, and IO inputs. It does not +retry parsing or rerun conversion hooks to discover the path. Hooks still see +their original exceptions and can recover from them; only an error escaping +the parse call is wrapped. If a hook parses a different input or handles +multiple errors before rethrowing an older one, the enclosing conversion may +be the most precise available location. If structural path recovery fails, +`path` is `nothing` and the original cause remains available. + +Diagnostics are off by default. Enabling them adds tracking to successful +conversions and a structural scan on failure. Omitting `error_context` gives the +lowest first-use cost; passing `false` disables tracking but may still add compiler work. +Existing partial updates from +`parse!` are preserved. Errors from validating options or recognizing the +initial input value, before materialization starts, keep their existing types; +interrupts, out-of-memory errors, and stack-overflow errors are never wrapped. + ### Arrays and collections You can parse JSON arrays directly into Julia arrays with a specific element type: diff --git a/src/JSON.jl b/src/JSON.jl index cd73c1d..b17e767 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -15,7 +15,7 @@ export JSONText, StructUtils, @noarg, @kwarg, @defaults, @tags, @choosetype, @no eval(Expr(:public, :parse, :parse!, :parsefile, :parsefile!, :lazy, :lazyfile, :LazyValue, - :isvalidjson, :DuplicateKeyError, + :isvalidjson, :DuplicateKeyError, :ParseError, :json, :print, :lower, :lift, :omit_null, :omit_empty, @@ -38,6 +38,29 @@ function Base.showerror(io::IO, err::DuplicateKeyError) Base.print(io, "duplicate JSON object key ", repr(err.key), " at byte position ", err.position) end +""" + JSON.ParseError + +An error encountered while constructing a value with `error_context=true`. +`path` is an RFC 6901 JSON Pointer relative to the input value, or `nothing` +if the path could not be recovered. Array indices are zero-based. `position` +is the one-based start byte of the failing value in the original input buffer. +`cause` and `backtrace` retain the original exception and its backtrace. +""" +struct ParseError <: Exception + path::Union{Nothing,String} + position::Int + cause::Any + backtrace::Any +end + +function Base.showerror(io::IO, err::ParseError) + Base.print(io, "JSON parse error") + err.path === nothing || Base.print(io, " at ", repr(err.path)) + Base.print(io, " (value starts at byte ", err.position, "): ") + Base.showerror(io, err.cause) +end + @enum Error InvalidJSON UnexpectedEOF ExpectedOpeningObjectChar ExpectedOpeningQuoteChar ExpectedOpeningArrayChar ExpectedClosingArrayChar ExpectedComma ExpectedColon ExpectedNewline InvalidChar InvalidNumber InvalidUTF16 @generated _typename(::Type{T}) where {T} = QuoteNode(string(T)) diff --git a/src/lazy.jl b/src/lazy.jl index 349b4dc..3a0542b 100644 --- a/src/lazy.jl +++ b/src/lazy.jl @@ -182,12 +182,12 @@ Selectors.@selectors LazyValues Base.lastindex(x::LazyValues) = length(x) # this ensures LazyValues can be "sources" in StructUtils.make -function StructUtils.applyeach(::StructUtils.StructStyle, f, x::LazyValues) +function StructUtils.applyeach(st::StructUtils.StructStyle, f, x::LazyValues) type = gettype(x) if type == JSONTypes.OBJECT - return applyobject(f, x) + return applyobject(_contextcallback(st, f), x) elseif type == JSONTypes.ARRAY - return applyarray(f, x) + return applyarray(_contextcallback(st, f), x) end typename = get(JSONTypes.names, type, "UNKNOWN") throw(ArgumentError(string( diff --git a/src/parse.jl b/src/parse.jl index 1ce1e9c..0d32fb8 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -19,6 +19,7 @@ Currently supported keyword arguments include: * `dicttype`: a custom `AbstractDict` type to use instead of `$DEFAULT_OBJECT_TYPE` as the default type for JSON object materialization * `null`: a custom value to use for JSON null values (default: `nothing`) * `unknown_fields`: controls how unmatched JSON object keys or positional values are handled when parsing into a target type or existing object; supported values are `:ignore` (default) and `:error` + * `error_context`: when `true`, wrap materialization failures in [`ParseError`](@ref) with an input path, value-start byte position, original exception and backtrace (default: `false`) * `style`: a custom `StructUtils.StructStyle` subtype instance to be used in calls to `StructUtils.make` and `StructUtils.lift`. This allows overriding default behaviors for non-owned types. @@ -153,14 +154,152 @@ abstract type JSONStyle <: StructStyle end # 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 +struct JSONReadStyle{O,T,S,C} <: JSONStyle null::T style::S ignore_unknown_fields::Bool + context::C end JSONReadStyle{O}(null::T, style::S=StructUtils.DefaultStyle(), ignore_unknown_fields::Bool=true) where {O,T,S} = - JSONReadStyle{O,T,S}(null, style, ignore_unknown_fields) + JSONReadStyle{O,T,S,Nothing}(null, style, ignore_unknown_fields, nothing) + +JSONReadStyle{O,T,S}(null::T, style::S, ignore_unknown_fields::Bool) where {O,T,S} = + JSONReadStyle{O,T,S,Nothing}(null, style, ignore_unknown_fields, nothing) + +struct _ParseFailure + position::Int + jsonlines::Bool + cause::Any + backtrace::Any +end + +mutable struct _ErrorContext{B} + buffer::B + failure::Union{Nothing,_ParseFailure} +end +_ErrorContext(x::LazyValues) = _ErrorContext(getbuf(x), nothing) + +_contextstyle(st::JSONReadStyle{O,T,S}, context::C) where {O,T,S,C} = + JSONReadStyle{O,T,S,C}(st.null, st.style, st.ignore_unknown_fields, context) + +_contextfatal(err) = err isa Union{InterruptException,OutOfMemoryError,StackOverflowError} + +@noinline function _recordfailure!(context::_ErrorContext, saved, err, x::LazyValues) + bt = catch_backtrace() + child = context.failure + # A hook may catch a failure and later throw the same exception again. + # Only a new child record with the same original backtrace is propagating. + if !(child !== nothing && child !== saved && child.cause === err && child.backtrace == bt) + context.failure = getbuf(x) === context.buffer ? + _ParseFailure(getpos(x), getopts(x).jsonlines, err, bt) : nothing + end + return context.failure +end + +struct _ContextCallback{F,C} + f::F + context::C +end +function (f::_ContextCallback)(k, v) + saved = f.context.failure + try + result = f.f(k, v) + f.context.failure = saved + return result + catch err + _contextfatal(err) && rethrow() + _recordfailure!(f.context, saved, err, v) + rethrow() + end +end + +@inline _contextcallback(ctx, f) = f +@inline _contextcallback(st::JSONReadStyle{O,T,S,<:_ErrorContext}, f) where {O,T,S} = + _ContextCallback(f, st.context) + +# The untyped parser carries its null value separately from typed style hooks. +struct _UntypedContext{N,C} + null::N + context::C +end +@inline _contextcallback(ctx::_UntypedContext, f) = _ContextCallback(f, ctx.context) +_untypednull(null) = null +_untypednull(ctx::_UntypedContext) = ctx.null +_untypedcontext(null, st::StructStyle) = null +_untypedcontext(null, st::JSONReadStyle{O,T,S,<:_ErrorContext}) where {O,T,S} = + _UntypedContext(null, st.context) +# A style supplied as the null value retains its existing applyvalue dispatch. +_untypedcontext(null::StructStyle, st::JSONReadStyle{O,T,S,<:_ErrorContext}) where {O,T,S} = null + +@inline _contextmake(st::StructStyle, ::Type{T}, x) where {T} = StructUtils.make(st, T, x) +function _contextmake(st::JSONReadStyle{O,N,S,<:_ErrorContext}, ::Type{T}, x) where {O,N,S,T} + saved = st.context.failure + try + result = StructUtils.make(st, T, x) + st.context.failure = saved + return result + catch err + _contextfatal(err) && rethrow() + _recordfailure!(st.context, saved, err, x) + rethrow() + end +end + +# Return a pointer suffix when found, otherwise the next byte position. Returning +# positions lets the existing traversal avoid rescanning completed subtrees. +function _failurepath(x::LazyValues, failure::_ParseFailure) + if getpos(x) == failure.position && getopts(x).jsonlines == failure.jsonlines + return StructUtils.EarlyReturn("") + end + type = gettype(x) + if type == JSONTypes.OBJECT || type == JSONTypes.ARRAY + callback = function (k, v) + getpos(v) > failure.position && return StructUtils.EarlyReturn(nothing) + found = _failurepath(v, failure) + if found isa StructUtils.EarlyReturn + found.value === nothing && return found + key = type == JSONTypes.ARRAY ? string(k - 1) : String(k) + token = replace(key, "~" => "~0", "/" => "~1") + return StructUtils.EarlyReturn(string('/', token, found.value)) + end + return found + end + return type == JSONTypes.OBJECT ? applyobject(callback, x) : applyarray(callback, x) + end + return skip(x) +end + +function _withparsecontext(f::F, root::LazyValue, context::_ErrorContext) where {F} + try + return f() + catch err + _contextfatal(err) && rethrow() + failure = _recordfailure!(context, nothing, err, root)::_ParseFailure + path = try + found = _failurepath(root, failure) + found isa StructUtils.EarlyReturn ? found.value : nothing + catch recovery + _contextfatal(recovery) && rethrow() + nothing + end + throw(ParseError(path, failure.position, failure.cause, failure.backtrace)) + end +end + +function _parsecontext(x, ::Type{T}, dicttype, null, style) where {T} + context = _ErrorContext(x) + return _withparsecontext(x, context) do + _parse(x, T, dicttype, null, _contextstyle(style, context)) + end +end + +function _parsecontext!(x, obj, style) + context = _ErrorContext(x) + return _withparsecontext(x, context) do + StructUtils.make!(_contextstyle(style, context), obj, x) + end +end objecttype(::StructStyle) = DEFAULT_OBJECT_TYPE objecttype(::JSONReadStyle{OT}) where {OT} = OT @@ -247,30 +386,47 @@ parsefile!(file, x::T; jsonlines::Union{Bool,Nothing}=nothing, kw...) where {T} parse(io::Union{IO,Base.AbstractCmd}, ::Type{T}=Any; kw...) where {T} = parse(Base.read(io), T; kw...) -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; +# Keep literal options visible through the mutating entrypoints in native builds. +@inline parse!(io::Union{IO,Base.AbstractCmd}, x::T; kw...) where {T} = parse!(Base.read(io), x; kw...) + +# Absence is known from the keyword tuple's type, so default calls do not infer +# the diagnostics path. Remove only this option before forwarding lazy options. +function _errorcontext(kw) + value = get(kw, :error_context, false) + value isa Bool || throw(TypeError(Symbol("keyword argument"), :error_context, Bool, value)) + return value +end + +_withoutcontext(kw) = Base.structdiff(values(kw), NamedTuple{(:error_context,)}) + +# Buffer customization stays at the StructUtils style/make/lift boundary; +# a parse method specialized on LazyValue applies to direct LazyValue inputs. +function 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} = - _parse(lazy(buf; kw...), T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) + unknown_fields::Symbol=:ignore, kw...) where {T,O} + error_context = _errorcontext(kw) + x = lazy(buf; _withoutcontext(kw)...) + st = jsonreadstyle(T, O, null, style, unknown_fields) + return error_context ? _parsecontext(x, T, dicttype, null, st) : _parse(x, T, dicttype, null, st) +end -parse!(buf::Union{AbstractVector{UInt8},AbstractString}, x::T; +@inline function 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} = - StructUtils.make!(jsonreadstyle(typeof(x), O, null, style, unknown_fields), x, lazy(buf; kw...)) + unknown_fields::Symbol=:ignore, kw...) where {T,O} + error_context = _errorcontext(kw) + st = jsonreadstyle(T, O, null, style, unknown_fields) + source = lazy(buf; _withoutcontext(kw)...) + return error_context ? _parsecontext!(source, x, st) : _parse!(source, x, st) +end -parse(x::LazyValue, ::Type{T}=Any; +function 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} = - _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) + unknown_fields::Symbol=:ignore, kw...) where {T,O} + error_context = _errorcontext(kw) + isempty(_withoutcontext(kw)) || Base.kwerr(values(kw), parse, x, T) + st = jsonreadstyle(T, O, null, style, unknown_fields) + return error_context ? _parsecontext(x, T, dicttype, null, st) : _parse(x, T, dicttype, null, st) +end function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} y, pos = StructUtils.make(style, T, x) @@ -285,17 +441,24 @@ end (f::ValueClosure)(v) = setfield!(f, :value, v) -function _parse(x::LazyValue, ::Type{Any}, ::Type{DEFAULT_OBJECT_TYPE}, null, ::StructStyle) +function _parse(x::LazyValue, ::Type{Any}, ::Type{DEFAULT_OBJECT_TYPE}, null, st::StructStyle) out = ValueClosure() - pos = applyvalue(out, x, null) + pos = applyvalue(out, x, _untypedcontext(null, st)) getisroot(x) && checkendpos(x, Any, pos) return out.value end -parse!(x::LazyValue, obj::T; +@inline function parse!(x::LazyValue, obj::T; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(), - unknown_fields::Symbol=:ignore) where {T,O} = - StructUtils.make!(jsonreadstyle(T, O, null, style, unknown_fields), obj, x) + unknown_fields::Symbol=:ignore, kw...) where {T,O} + error_context = _errorcontext(kw) + isempty(_withoutcontext(kw)) || Base.kwerr(values(kw), parse!, x, obj) + st = jsonreadstyle(T, O, null, style, unknown_fields) + return error_context ? _parsecontext!(x, obj, st) : _parse!(x, obj, st) +end + +# Compile typed mutation separately from the inlined keyword forwarders. +@noinline _parse!(x::LazyValue, obj, style::StructStyle) = StructUtils.make!(style, obj, x) # for LazyValue, if x started at the beginning of the JSON input, # then we want to ensure that the entire input was consumed @@ -326,7 +489,7 @@ mutable struct ObjectClosure{T} obj::Object{String,Any} keys::Union{Nothing,Set{String}} count::Int - ctx::T # the null value of an untyped parse, or the style of a typed one + ctx::T # the null value/diagnostic context of an untyped parse, or a typed style end ObjectClosure(obj, ctx) = ObjectClosure(obj, obj, nothing, 0, ctx) @@ -370,7 +533,7 @@ function applyvalue(f, x::LazyValues, null) type = gettype(x) if type == JSONTypes.OBJECT obj = Object{String,Any}() - pos = applyobject(ObjectClosure(obj, null), x) + pos = applyobject(_contextcallback(null, ObjectClosure(obj, null)), x) f(obj) return pos elseif type == JSONTypes.ARRAY @@ -378,9 +541,7 @@ function applyvalue(f, x::LazyValues, null) # a reallocation in many cases arr = Vector{Any}(undef, 16) resize!(arr, 0) - pos = applyarray(x) do _, v - applyvalue(val -> push!(arr, val), v, null) - end + pos = applyarray(_contextcallback(null, (_, v) -> applyvalue(val -> push!(arr, val), v, null)), x) f(arr) return pos elseif type == JSONTypes.STRING @@ -403,7 +564,7 @@ function applyvalue(f, x::LazyValues, null) end return pos elseif type == JSONTypes.NULL - f(null) + f(_untypednull(null)) return getpos(x) + 4 elseif type == JSONTypes.TRUE f(true) @@ -506,7 +667,7 @@ function StructUtils.make(st::_DefaultReadStyle, ::Type{Vector{Any}}, x::LazyVal gettype(x) == JSONTypes.ARRAY || return @invoke StructUtils.make(st::StructStyle, Vector{Any}::Type, x::Any) arr = sizehint!(StructUtils.initialize(st, Vector{Any}, x), 16) - pos = applyarray((_, v) -> applyvalue(val -> push!(arr, val), v, st), x) + pos = applyarray(_contextcallback(st, (_, v) -> applyvalue(val -> push!(arr, val), v, st)), x) return arr, pos end @@ -516,7 +677,7 @@ function StructUtils.make(st::_DefaultReadStyle, ::Type{Object{String,Any}}, x:: gettype(x) == JSONTypes.OBJECT || return @invoke StructUtils.make(st::StructStyle, Object{String,Any}::Type, x::Any) obj = StructUtils.initialize(st, Object{String,Any}, x) - pos = applyobject(ObjectClosure(obj, st), x) + pos = applyobject(_contextcallback(st, ObjectClosure(obj, st)), x) return obj, pos end @@ -524,7 +685,7 @@ function StructUtils.make(st::_DefaultReadStyle, ::Type{T}, x::LazyValues) where gettype(x) == JSONTypes.OBJECT || return @invoke StructUtils.make(st::StructStyle, T::Type, x::Any) dict = StructUtils.initialize(st, T, x) - pos = applyobject((k, v) -> applyvalue(val -> StructUtils.addkeyval!(dict, StructUtils.liftkey(st, String, k), val), v, st), x) + pos = applyobject(_contextcallback(st, (k, v) -> applyvalue(val -> StructUtils.addkeyval!(dict, StructUtils.liftkey(st, String, k), val), v, st)), x) return dict, pos end @@ -698,7 +859,7 @@ end @nextbyte end x = _lazy(buf, pos, len, b, opts) - j_{i}, pos = StructUtils.make(st, fieldtype(T, i), x) + j_{i}, pos = _contextmake(st, fieldtype(T, i), x) @nextbyte if typ == JSONTypes.OBJECT && b == UInt8('}') if Base.@nany($N, k->!@isdefined(j_{k})) diff --git a/test/error_context.jl b/test/error_context.jl new file mode 100644 index 0000000..1cead7b --- /dev/null +++ b/test/error_context.jl @@ -0,0 +1,261 @@ +module ErrorContextTests +using JSON, StructUtils, Test + +struct Percent + value::Float64 +end +struct Entry + percent::Percent +end +struct Envelope + path::Vector{Entry} +end + +JSON.@nonstruct struct Checked + value::Int +end +const seen = Int[] +const rejection = DomainError(-2, "must be nonnegative") +function checked_lift(value) + push!(seen, value) + value < 0 && throw(rejection) + return Checked(value) +end +JSON.lift(::Type{Checked}, value) = checked_lift(value) + +JSON.@tags struct Renamed + count::Int &(json=(name="a/b~c",),) +end + +JSON.@noarg mutable struct MutableEntry + first::Int + second::Int +end + +struct ContextStyle <: StructUtils.StructStyle end +StructUtils.lift(::ContextStyle, ::Type{Checked}, value::Integer) = (checked_lift(value), nothing) + +JSON.@nonstruct struct FatalValue end +const fatal = Ref{Exception}(InterruptException()) +JSON.lift(::Type{FatalValue}, value) = throw(fatal[]) + +struct Recovered + values::Vector{Int} +end +const caught = Ref{Any}(nothing) +function StructUtils.make(st::StructUtils.StructStyle, ::Type{Recovered}, x::JSON.LazyValue) + try + values, pos = StructUtils.make(st, Vector{Checked}, x) + return Recovered(getfield.(values, :value)), pos + catch err + caught[] = err + err isa DomainError || rethrow() + values, pos = StructUtils.make(st, Vector{Int}, x) + return Recovered(values), pos + end +end + +struct MethodRecovered + values::Vector{Any} +end +function StructUtils.make(st::StructUtils.StructStyle, ::Type{MethodRecovered}, x::JSON.LazyValue) + try + values, pos = StructUtils.make(st, Vector{Int}, x) + return MethodRecovered(values), pos + catch err + err isa MethodError || rethrow() + values, pos = StructUtils.make(st, Vector{Any}, x) + return MethodRecovered(values), pos + end +end + +struct NumberStyle <: StructUtils.StructStyle end +StructUtils.lift(::NumberStyle, ::Type{Number}, value::Integer) = (value + 100, nothing) + +struct ForeignSource end +StructUtils.make(st::StructUtils.StructStyle, ::Type{ForeignSource}, x::JSON.LazyValue) = + StructUtils.make(st, Vector{Int}, JSON.lazy("[\"foreign\"]")) + +struct Retried{mode} end +throw_again() = throw(rejection) +function StructUtils.make(st::StructUtils.StructStyle, ::Type{Retried{mode}}, x::JSON.LazyValue) where {mode} + for name in (:attempt, :final) + try + StructUtils.make(st, Vector{Checked}, getproperty(x, name)) + catch + if mode === :throw + throw_again() + elseif mode === :rethrow + StructUtils.make(st, Vector{Int}, x.final) + rethrow() + elseif name === :final + rethrow() + end + end + end + error("expected a conversion failure") +end + +captured(f) = try f(); nothing catch err; err end + +function checkerror(text, target, path, token, cause; kw...) + old = captured(() -> JSON.parse(text, target; kw...)) + @test old isa cause + err = captured(() -> JSON.parse(text, target; kw..., error_context=true)) + @test err isa JSON.ParseError + err isa JSON.ParseError || return err + @test err.path == path + @test err.position == first(findfirst(token, text)) + @test err.cause isa cause + @test typeof(err.cause) === typeof(old) + @test !isempty(err.backtrace) + @test occursin("value starts at byte", sprint(showerror, err)) + return err +end + +@testset "opt-in JSON error context" begin + @testset "keyword forwarding" begin + for source in (identity, IOBuffer, JSON.lazy) + for context in (false, true) + @test JSON.parse(source("{\"value\":7}"), Percent; error_context=context).value == 7 + value = MutableEntry() + JSON.parse!(source("{\"first\":7,\"second\":8}"), value; error_context=context) + @test (value.first, value.second) == (7, 8) + @test captured(() -> JSON.parse(source("1"), Int; + unsupported_option=true, error_context=context)) isa MethodError + @test captured(() -> JSON.parse!(source("{}"), MutableEntry(); + unsupported_option=true, error_context=context)) isa MethodError + end + for invalid in (nothing, 1, :enabled) + for call in (() -> JSON.parse(source("1"), Int; error_context=invalid), + () -> JSON.parse!(source("{}"), MutableEntry(); error_context=invalid)) + err = captured(call) + @test err isa TypeError + @test (err.func, err.context, err.expected, err.got) == + (Symbol("keyword argument"), :error_context, Bool, invalid) + end + end + end + for context in (false, true) + @test JSON.parse("1 trailing", Int; isroot=false, error_context=context) == 1 + @test JSON.parse("1\n2\n", Vector{Int}; jsonlines=true, error_context=context) == [1, 2] + end + end + + checkerror("{\"path\":[{\"percent\":{\"value\":0.1}},{\"percent\":0.1}]}", + Envelope, "/path/1/percent", "0.1}]", ArgumentError) + checkerror("{\"rows\":[[1,2],[3,\"bad\"]]}", @NamedTuple{rows::Vector{Vector{Int}}}, + "/rows/1/1", "\"bad\"", MethodError) + checkerror("{\"rows\":[[1,2],[3,1.5]]}", @NamedTuple{rows::Vector{Vector{Int}}}, + "/rows/1/1", "1.5", InexactError; allownan=true) + checkerror("{\"path\":[{\"percent\":{\"value\":0.1,\"oops\":3}}]}", + Envelope, "/path/0/percent/oops", "3", ArgumentError; unknown_fields=:error) + checkerror("{\"path\":[{\"percent\":{}}]}", Envelope, + "/path/0/percent", "{}", Union{ArgumentError,TypeError}) + checkerror("{\"rows\":[[1,2],[3,]]}", @NamedTuple{rows::Vector{Vector{Int}}}, + "/rows/1", "[3,]", ArgumentError) + checkerror("{\"a/b~c\":\"bad\"}", Renamed, + "/a~1b~0c", "\"bad\"", MethodError) + checkerror("{\"a/b\":{\"m~n\":[1,\"bad\"]}}", Dict{String,Dict{String,Vector{Int}}}, + "/a~1b/m~0n/1", "\"bad\"", MethodError) + checkerror("{\"\":{\"\\u03bb\":\"bad\"}}", Dict{String,Dict{String,Int}}, + "//λ", "\"bad\"", MethodError) + checkerror("[1,\"bad\"]", Tuple{Int,Int}, "/1", "\"bad\"", MethodError) + checkerror("{\"left\":1,\"right\":\"bad\"}", Tuple{Int,Int}, + "/right", "\"bad\"", MethodError) + checkerror("\"bad\"", Int, "", "\"bad\"", MethodError) + + for style in (StructUtils.DefaultStyle(), ContextStyle()) + empty!(seen) + err = captured(() -> JSON.parse("[1,-2,3]", Vector{Checked}; style, error_context=true)) + @test err isa JSON.ParseError + @test err.path == "/1" + @test err.cause === rejection + @test seen == [1,-2] + @test any(frame -> frame.func == :checked_lift, stacktrace(err.backtrace)) + end + + for value in (InterruptException(), OutOfMemoryError(), StackOverflowError()) + fatal[] = value + @test captured(() -> JSON.parse("[1]", Vector{FatalValue}; error_context=true)) === value + end + + @testset "custom conversion recovery" begin + for context in (false, true) + empty!(seen) + @test JSON.parse("[1,-2,3]", Recovered; error_context=context).values == [1,-2,3] + @test caught[] === rejection + @test seen == [1,-2] + @test JSON.parse("[1,\"fallback\"]", MethodRecovered; error_context=context).values == [1,"fallback"] + @test JSON.parse("[1,{\"x\":2}]"; style=NumberStyle(), error_context=context) == + [1,JSON.Object("x" => 2)] + end + err = captured(() -> JSON.parse("[100,200]", ForeignSource; error_context=true)) + @test err.path == "" + @test err.position == 1 + @test err.cause isa MethodError + + # Preserve the existing dispatch when a style is itself passed as null. + nullstyle = StructUtils.DefaultStyle() + @test captured(() -> JSON.parse("null"; null=nullstyle)) isa MethodError + err = captured(() -> JSON.parse("null"; null=nullstyle, error_context=true)) + @test err.cause isa MethodError + @test err.path == "" + nullstyle = JSON.JSONReadStyle{JSON.Object{String,Any}}(missing) + for context in (false, true) + @test JSON.parse("null"; null=nullstyle, error_context=context) === missing + end + + text = "{\"attempt\":[-2],\"final\":[-2]}" + for (mode, path, position) in ((:retry, "/final/0", first(findlast("-2", text))), + (:throw, "", 1), + (:rethrow, "/attempt/0", first(findfirst("-2", text)))) + err = captured(() -> JSON.parse(text, Retried{mode}; error_context=true)) + @test err.path == path + @test err.position == position + @test err.cause === rejection + mode === :throw && @test any(frame -> frame.func == :throw_again, stacktrace(err.backtrace)) + end + end + + for (text, path, pos) in (("{}\n{\"value\":1}\n", "/0", 1), + ("{\"value\":1}\n{}\n", "/1", 13)) + err = captured(() -> JSON.parse(text, Vector{Percent}; jsonlines=true, error_context=true)) + @test err isa JSON.ParseError + @test err.path == path + @test err.position == pos + end + + text = "{\"outer\":{\"value\":\"bad\"}}" + subtree = JSON.lazy(text).outer + err = captured(() -> JSON.parse(subtree, Percent; error_context=true)) + @test err.path == "/value" + @test err.position == first(findfirst("\"bad\"", text)) + + for input in ("{\"first\":7,\"second\":\"bad\"}", IOBuffer("{\"first\":7,\"second\":\"bad\"}"), + JSON.lazy("{\"first\":7,\"second\":\"bad\"}")) + value = MutableEntry() + value.first, value.second = 1, 2 + err = captured(() -> JSON.parse!(input, value; error_context=true)) + @test err.path == "/second" + @test err.cause isa MethodError + @test (value.first, value.second) == (7,2) + end + + mktemp() do path, io + write(io, "{\"value\":\"bad\"}") + close(io) + err = captured(() -> JSON.parsefile(path, Percent; error_context=true)) + @test err.path == "/value" + end + + for text in ("null", "true", "1", "1.5", "\"hello\"", "[1,{\"a\":2}]", "{\"a\":1,\"a\":2}") + @test isequal(JSON.parse(text; error_context=true), JSON.parse(text)) + @test isequal(JSON.parse(IOBuffer(text); error_context=true), JSON.parse(text)) + end + err = captured(() -> JSON.parse("{\"a\":{\"x\":1,\"x\":2}}"; duplicate_keys=:error, error_context=true)) + @test err isa JSON.ParseError + @test err.path == "/a" + @test err.cause isa JSON.DuplicateKeyError +end +end diff --git a/test/json_trim_public_entrypoints.jl b/test/json_trim_public_entrypoints.jl index f138f87..c182446 100644 --- a/test/json_trim_public_entrypoints.jl +++ b/test/json_trim_public_entrypoints.jl @@ -76,6 +76,9 @@ function exercise_parse_entrypoints()::Nothing checked((name::String) == "Ada", "untyped string value failed") checked(JSON.parse("7", Int) == 7, "typed scalar parse failed") + checked(JSON.parse("7", Int; error_context=false) == 7, "disabled diagnostics parse failed") + checked(JSON.parse(JSON.lazy(ARRAY_JSON), Vector{Int}; error_context=false) == [1, 2, 3], + "disabled diagnostics lazy 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), @@ -90,6 +93,13 @@ function exercise_parse_entrypoints()::Nothing "nested vector parse failed") checked(root.tags == ["a"] && root.note === nothing, "nullable field parse failed") + disabled = JSON.parse( + "{\"item\":null,\"items\":[],\"tags\":[],\"note\":null}", TrimRoot; + error_context=false, + ) + checked(disabled.item === nothing && isempty(disabled.items), + "disabled diagnostics nested parse failed") + tagged = JSON.parse( "{\"wire\":4,\"secret\":99}", TrimTagged; @@ -100,6 +110,10 @@ function exercise_parse_entrypoints()::Nothing mutable_value = TrimMutable() JSON.parse!("{\"value\":8}", mutable_value; unknown_fields=:error) checked(mutable_value.value == 8, "parse! failed") + JSON.parse!(IOBuffer("{\"value\":9}"), mutable_value; error_context=false) + checked(mutable_value.value == 9, "disabled diagnostics IO parse! failed") + JSON.parse!(JSON.lazy("{\"value\":10}"), mutable_value; error_context=false) + checked(mutable_value.value == 10, "disabled diagnostics lazy parse! failed") unknown = try JSON.parse("{\"extra\":1}", TrimLeaf; unknown_fields=:error) diff --git a/test/runtests.jl b/test/runtests.jl index ec1f848..4de98a1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,6 +5,7 @@ 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/error_context.jl")) include(joinpath(dirname(pathof(JSON)), "../test/json.jl")) include(joinpath(dirname(pathof(JSON)), "../test/style_fastpaths.jl")) # Arrow.jl is broken on 32 bit systems for now :(