diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 68e7f63..64d79bb 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,6 +23,18 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} + - name: Drop JET deps on Julia versions JET does not support + # JET requires 1.7 <= Julia <= 1.12, so Pkg cannot resolve + # JET on Julia 1.6 or on nightly. Strip JET from Project.toml + # (compat + extras + targets) and from runtests.jl on those + # rows so the rest of the test suite still runs green. + # The job using a registered JET-supported Julia version ('1') + # keeps full JET coverage. + if: matrix.version == 'nightly' || matrix.version == '1.6' + run: | + sed -i '/^JET = /d' Project.toml + sed -i 's/, "JET"//' Project.toml + sed -i '/^@testset "JET" begin$/,/^end$/d' test/runtests.jl - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 diff --git a/Project.toml b/Project.toml index 30dc4c4..0bba75f 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,8 @@ ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" [compat] Aqua = "0.8, 1" ConstructionBase = "1.3" +JET = "0.9, 0.10, 0.11" +StaticArrays = "1" StructTypes = "1" # `Test` is a stdlib. On Julia < 1.11 it is reported as unversioned # (matched by `<0.0.1`); on Julia >= 1.11 it has a real version (matched @@ -19,8 +21,10 @@ julia = "1.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" +StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "Test", "StructTypes"] +test = ["Aqua", "JET", "StaticArrays", "StructTypes", "Test"] diff --git a/README.md b/README.md index 7c9b841..125b3bd 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,34 @@ For all supported options and defaults, consult the docstring: julia>?@batteries ``` +## `kwshow` vs `showrepr` + +Two options overload `Base.show`; pick at most one (passing both is an +error): + +* `kwshow=true` always renders `T(f1 = v1, f2 = v2, …)` — every field, + named, in declaration order. The output shape is fixed and stable + across versions, which makes it well-suited to diagnostics and + golden-file tests. It round-trips through `eval` only when the type + has a keyword constructor (e.g. via `kwconstructor=true` or + `Base.@kwdef`). + +* `showrepr=true` prints a heuristically short constructor call that + recreates the object. It probes every constructor of the type + (positional, keyword, hybrid), omits trailing fields that already + match a default, and substitutes shorter literals where the + constructor still accepts them (e.g. `0x2a` → `42`, `2//1` → `2`, + uniform vectors → `fill(v, n)`). Because the result depends on the + field values and on the package's heuristics, the exact output is + *not* guaranteed to be stable across minor releases — don't pin + golden files against it. If no constructor recreates the object, + `showrepr` falls back to a non-executable `T(field = value, …)` + rendering. + +Rule of thumb: pick `kwshow` if you want a predictable, name-every-field +diagnostic; pick `showrepr` if you want `Base.show` to produce an +idiomatic, recreatable, as-short-as-reasonable form for end users. + # Alternatives * [AutoHashEquals](https://github.com/andrewcooke/AutoHashEquals.jl) requires annotating the struct definition. This can be inconvenient if you want to annotate the definition with another macro as well. diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 3031bd8..50470fc 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -7,6 +7,8 @@ export @enumbattery import ConstructionBase: getproperties, constructorof, setproperties +include("showrepr.jl") + # `$SH.foo` in macro emissions interpolates the StructHelpers module value # directly into the generated AST. This resolves via `getproperty` at the # call site without requiring `import StructHelpers` (or any caller-side @@ -112,6 +114,16 @@ end nt = Tuple(getproperties(o)) Base.hash(nt, h) end +""" + kwshow(io::IO, o) + +Show `o` as `T(f1 = v1, f2 = v2, ...)`, listing every field as a keyword +argument. The output is recreatable via the keyword constructor (see +[`@batteries`](@ref) with `kwconstructor=true`). + +For a representation that picks the shortest constructor call and omits +default values, see [`showrepr`](@ref). +""" function kwshow(io::IO, o) print(io, typeof(o)) print(io, "(") @@ -173,6 +185,7 @@ const BATTERIES_DEFAULTS = ( kwconstructor = false, selfconstructor = true, kwshow = false, + showrepr = false, getproperties = true , constructorof = true , typesalt = nothing, @@ -186,6 +199,7 @@ const BATTERIES_DOCSTRINGS = ( kwconstructor = "Add a keyword constructor. Defaults can be supplied by overloading [`default_keywords`](@ref).", selfconstructor = "Add a constructor of the for `T(self::T) = self`", kwshow = "Overload `Base.show` such that the names of each field are printed. Fields whose value is `isequal` to the value returned by [`default_keywords`](@ref) are omitted.", + showrepr = "Overload `Base.show` to print a heuristically short constructor call that recreates the object, omitting default values. Mutually exclusive with `kwshow`.", getproperties = "Overload `ConstructionBase.getproperties`.", constructorof = "Overload `ConstructionBase.constructorof`.", typesalt = "Only used if `hash=true`. In this case the `hash` will be purely computed from `typesalt` and `hash_eq_as(obj)`. The type `T` will not be used otherwise. This makes the hash more likely to stay constant, when executing on a different machine or julia version", @@ -350,9 +364,7 @@ function def_batteries(__module__, T, kw, base) ret = quote end need_fieldnames = nt.kwconstructor || nt.getproperties - if need_fieldnames - fieldnames = Base.fieldnames(Base.eval(__module__, T)) - end + fieldnames = need_fieldnames ? Base.fieldnames(Base.eval(__module__, T)) : () need_StructTypes = nt.StructTypes if need_StructTypes push!(ret.args, :(import StructTypes as $ST)) @@ -380,10 +392,17 @@ function def_batteries(__module__, T, kw, base) ) push!(ret.args, def) end + if nt.kwshow && nt.showrepr + error("`kwshow` and `showrepr` are mutually exclusive; please pick at most one.") + end if nt.kwshow def = :($(Base).show(io::$IO, o::$T) = $(kwshow)(io, o)) push!(ret.args, def) end + if nt.showrepr + def = :(Base.show(io::IO, o::$T) = $(showrepr)(io, o)) + push!(ret.args, def) + end if nt.getproperties def = def_getproperties(T, fieldnames) push!(ret.args, def) diff --git a/src/showrepr.jl b/src/showrepr.jl new file mode 100644 index 0000000..8059741 --- /dev/null +++ b/src/showrepr.jl @@ -0,0 +1,329 @@ +# Implementation of the `showrepr` battery: a heuristically short +# `Base.show` that round-trips through the constructor(s) of the type. +# The `showrepr=true` flag of `@batteries` / `@battery` (parsed in +# `StructHelpers.jl`) emits `Base.show(io, ::T) = showrepr(io, o)`, +# which dispatches into the code here. +# +# Everything in this file is `StructHelpers`-internal except for the two +# documented entry points `showrepr` and `constructor_repr`, plus the +# `repr_eq` predicate that is exposed because it is referenced in +# user-facing docstrings. + +""" + showrepr(io::IO, o) + +Show `o` using a heuristically short string representation that recreates `o`, +chosen among the constructors of `typeof(o)`. Equality with a candidate +reconstruction is checked via [`repr_eq`](@ref). + +Trailing fields whose values match a default — be it from a positional default +(`T(a, b=2) = ...`) or a keyword constructor (e.g. via `Base.@kwdef`) — are +omitted. The result is not guaranteed to be globally shortest; see +[`constructor_repr`](@ref) for the search strategy. +""" +function showrepr(io::IO, o) + print(io, constructor_repr(o)) +end + +# Invoke `T(pos...; kws...)` with `@nospecialize`d `T`. Inference resolves +# the call against abstract `::Type` rather than a concrete `Type{X}`, so +# JET (e.g. `report_package` on a downstream package) does not flag +# `Core.kwcall` as missing for types `X` whose constructors take no kwargs +# — those calls are guarded at runtime by the surrounding `try/catch`. +function invoke_ctor(@nospecialize(T::Type), pos_vals::Vector{Any}, kw_pairs) + T(pos_vals...; kw_pairs...) +end + +""" + constructor_repr(o)::String + +Return a short string of the form `T(...)` that, when parsed and evaluated, +recreates `o` (according to [`repr_eq`](@ref)). + +All constructors of `T = typeof(o)` are considered — positional, keyword, +and mixed — and trailing fields whose values match a default are omitted. +If no constructor recreates `o`, falls back to a non-executable +`T(field = value, ...)` rendering. + +The result is heuristic and not guaranteed to be the globally shortest valid +representation: for each constructor a single greedy pass eliminates kwargs +whose defaults match, then a greedy pass substitutes shorter literal forms +for individual fields (e.g. unsigned hex → decimal, whole-valued floats → +integers, `2//1` → `2`, `2 + 0im` → `2`). The shortest candidate produced +this way is returned. +""" +function constructor_repr(o) + T = typeof(o) + fnames = fieldnames(T) + nf = length(fnames) + # Compare reconstructions to `o` by fields rather than via `o ==`. That + # way mutable structs without an explicit `==` (whose default `==` is + # `===`) still recognize a fresh reconstruction as equivalent. Going + # through `repr_eq` on the `NamedTuple` retains tolerance for `NaN` + # leaves and constructors that normalize `-0.0`/`0.0`. + matches(x) = repr_eq(getproperties(x), getproperties(o)) + + candidates = String[] + seen_strings = Set{String}() + + # Probe every method of `T`. For each `m` we try + # + # T(o.f1, ..., o.fnp; k1 = o.k1, ..., kn = o.kn) + # + # where `np` is `m`'s positional arity and `k1..kn` are the kwargs + # declared by `m` that share a name with a field. Positional defaults + # (`T(a, b=2)`) show up as separate methods with smaller `np`; + # `Base.@kwdef` contributes both a positional and a keyword form. We + # then greedily drop each kwarg whose default already matches `o`. + for m in methods(T) + # Vararg constructors (`T(args...)`): unclear how many fields to splat. + m.isva && continue + # `m.nargs` includes the `Type{T}` slot. + np = m.nargs - 1 + np > nf && continue + # `Base.kwarg_decl` is internal, but the only known way to + # introspect a method's kwargs. + kws = Base.kwarg_decl(m) + # Explicit filter rather than `kws ∩ fnames`: for `Tuple` types + # `fieldnames(T)` returns integers, which makes `∩` dispatch to a + # JET-unstable path. The filter has a uniform `Vector{Symbol}` + # return type. + relevant_kws = Symbol[k for k in kws if k in fnames] + + # `keep_kws` is only ever rebound, so the alias is safe (no `copy`). + keep_kws = relevant_kws + + # Mutable so the literal-shortening pass below can write into it. + pos_vals = Any[getfield(o, i) for i in 1:np] + + # `T(...)` may legitimately throw (custom invariants, type errors, + # ...); treat any such candidate as a non-match. Routed through + # `invoke_ctor` so JET stays clean on types without a kwarg ctor. + recreates(kws) = try + matches(invoke_ctor(T, pos_vals, (k => getfield(o, k) for k in kws))) + catch + false + end + + recreates(keep_kws) || continue + + # Drop each kwarg whose default already matches. Independent per + # kwarg, so a single forward pass suffices. + for k in relevant_kws + trial = filter(!isequal(k), keep_kws) + recreates(trial) && (keep_kws = trial) + end + + # Try to substitute each field with a shorter literal (see `simple`), + # accepting only when `T(...)` still recreates `o`. + kw_vals = Dict{Symbol,Any}(k => getfield(o, k) for k in keep_kws) + trycall() = try + matches(invoke_ctor(T, pos_vals, (k => kw_vals[k] for k in keep_kws))) + catch + false + end + for i in 1:np; simplify!(pos_vals, i, trycall); end + for k in keep_kws; simplify!(kw_vals, k, trycall); end + + # Format `T(pos...; kws...)`. For each field try `compactify!` first + # (e.g. uniform `Vector` → `fill(v, n)`), then fall back to `show`. + buf = IOBuffer() + print(buf, T, "(") + sep = false + for i in 1:np + sep && print(buf, ", "); sep = true + s = compactify!(pos_vals, i, trycall) + s === nothing ? show(buf, pos_vals[i]) : print(buf, s) + end + for k in keep_kws + sep && print(buf, ", "); sep = true + print(buf, k, " = ") + s = compactify!(kw_vals, k, trycall) + s === nothing ? show(buf, kw_vals[k]) : print(buf, s) + end + print(buf, ")") + # Dedup identical forms from different methods (e.g. `Base.@kwdef`'s + # positional + keyword constructors on a fully-default object). + s = String(take!(buf)) + if s ∉ seen_strings + push!(seen_strings, s) + push!(candidates, s) + end + end + + # No constructor recreated `o` (mutating, context-dependent, or rejects + # the field values). Fall back to a named-tuple rendering — informative + # but not directly executable. + if isempty(candidates) + buf = IOBuffer() + print(buf, T) + show(buf, getproperties(o)) + return String(take!(buf)) + end + return candidates[argmin(map(length, candidates))] +end + +""" + repr_eq(a, b)::Bool + +Test whether `a` and `b` are equal in a sense suitable for checking that a +candidate constructor call recreates an object faithfully enough for +`show`/`repr` round-tripping. + +Returns `true` iff `a == b` (resolving to `true`) **or** `isequal(a, b)`. + +Both predicates are used because each one alone would reject legitimate +reconstructions: + +* `==` returns `false` for `NaN == NaN`, while `isequal(NaN, NaN)` is `true`. + Without the `isequal` fallback, a struct containing `NaN` could never be + recognized as recreated. +* `isequal(0.0, -0.0)` is `false`, but `0.0 == -0.0` is `true`. A constructor + that normalizes `-0.0` to `0.0`, or a type with a custom `==` (e.g. via + [`@batteries`](@ref) or [`hash_eq_as`](@ref)), would be rejected if only + `isequal` were used. + +`==` may also return non-`Bool` values (e.g. `missing`, or three-valued +logic from user-defined types). The `=== true` guard rejects those cleanly, +so they fall through to `isequal` instead of throwing in a boolean context. +""" +@inline repr_eq(a, b) = (a == b) === true || isequal(a, b) + +# Internal helpers used by `constructor_repr` to shorten the printed form of +# a field while keeping it round-trippable. Two independent axes: +# +# role | scalar value | collection rendering +# -------------------+--------------+---------------------- +# produce candidate | `simple` | `compact` +# apply to slot | `simplify!` | `compactify!` +# +# Producers may be liberal — every candidate is verified by a constructor +# probe before being installed. Collection candidates are returned as +# `(string, value)` pairs because the printed form may use a different +# concrete type than the original (e.g. `SVector` → `Vector`). + +# For any `Number`, the first attempt is `Int(v)`: an `Int` literal is the +# shortest possible repr and is accepted by the widest range of constructors +# via `convert(::Type{T}, ::Int)`. The check is value-based, so `UInt64(42)`, +# `Float64(2.0)`, `2//1`, `2 + 0im` all collapse to `42`/`2`. `Int(v)` is +# wrapped in a bare `catch` because user-defined `Number` subtypes (e.g. +# `Unitful.Quantity`) may throw `MethodError` rather than `InexactError`. +# `-0.0` is guarded explicitly: `Int(-0.0)` silently succeeds and would lose +# the sign bit. +simple(v) = nothing +function simple(v::Number) + v isa AbstractFloat && iszero(v) && signbit(v) && return nothing + try + return Int(v) + catch + end + v isa Unsigned && return signed(widen(v)) + v isa Rational && return isone(denominator(v)) ? something(simple(numerator(v)), numerator(v)) : nothing + v isa Complex && return iszero(imag(v)) ? something(simple(real(v)), real(v)) : nothing + return nothing +end + +# Apply `simple` to `coll[key]`, accepting only if the substitute is strictly +# shorter and `trycall()` still passes. Works on any container supporting +# `getindex`/`setindex!`. +function simplify!(coll, key, trycall) + old = coll[key] + alt = simple(old) + alt === nothing && return + length(repr(alt)) < length(repr(old)) || return + coll[key] = alt + trycall() || (coll[key] = old) + return +end + +# For `AbstractVector`, group consecutive `repr_eq` elements into runs and +# render each run as either a literal sequence or a splat — `fill(v, k)...` +# for `isbits` values, `[v for _ = 1:k]...` otherwise so mutable elements +# aren't aliased on re-eval — picking whichever is shorter per run. +# +# `compact_pieces` returns the un-wrapped run rendering and a tag: +# * `:uniform` — single repeated element, e.g. `"fill(0, 4)"`. Already +# stands alone (a function call, not an argument list). +# * `:multi` — comma-separated argument list, e.g. `"1, fill(0, 3)..."`. +# A wrapper has to bracket it (`[...]`) or splat it into a constructor. +# +# `compact(::AbstractVector)` then picks the wrapper based on whether the +# concrete type's `constructorof` accepts positional varargs of element +# values: `Vector` doesn't (so we bracket), `SVector`/`MVector`/`Tuple` +# do (so we emit `Tc(elems...)` and the static size shows up in the repr). +function compact_pieces(v::AbstractVector) + n = length(v) + n == 0 && return nothing + + runs = Tuple{Any,Int}[] + cur = first(v) + cnt = 1 + for i in 2:n + x = v[i] + if repr_eq(x, cur) + cnt += 1 + else + push!(runs, (cur, cnt)) + cur = x + cnt = 1 + end + end + push!(runs, (cur, cnt)) + + splat_form(val, k) = isbits(val) ? "fill($(repr(val)), $k)..." : + "[$(repr(val)) for _ = 1:$k]..." + + if length(runs) == 1 + first_v = first(v) + elem_repr = repr(first_v) + str = isbits(first_v) ? "fill($elem_repr, $n)" : + "[$elem_repr for _ = 1:$n]" + return (str, :uniform) + end + + pieces = String[] + for (val, k) in runs + literal = join(fill(repr(val), k), ", ") + splat = splat_form(val, k) + push!(pieces, length(splat) < length(literal) ? splat : literal) + end + return (join(pieces, ", "), :multi) +end + +compact(v) = nothing +function compact(v::AbstractVector) + cp = compact_pieces(v) + cp === nothing && return nothing + pieces, kind = cp + Tc = constructorof(typeof(v)) + # Prefer `Tc(elems...)` so the printed form preserves the concrete type + # (notably `SVector`'s static size). Fall back to a bracket literal when + # the wrapped form doesn't round-trip — `Vector` rejects the signature, + # and on Julia 1.6 `constructorof(SVector{N,T}) === SArray` throws + # `DimensionMismatch` since size can't be inferred from positional args. + fallback, wrapped = kind === :uniform ? + (pieces, "$Tc($pieces...)") : + ("[$pieces]", "$Tc($pieces)") + str = try + repr_eq(Tc(v...), v) ? wrapped : fallback + catch + fallback + end + length(str) < length(repr(v)) || return nothing + return (str, collect(v)) +end + +# Apply `compact` to `coll[key]`, accepting only if `trycall()` still passes. +# On success returns the rendered string and leaves `coll[key]` bound to the +# substitute value (`repr_eq` to the original); on failure restores the +# original and returns `nothing`. +function compactify!(coll, key, trycall) + orig = coll[key] + cr = compact(orig) + cr === nothing && return nothing + str, alt = cr + coll[key] = alt + trycall() && return str + coll[key] = orig + return nothing +end diff --git a/test/runtests.jl b/test/runtests.jl index 35ab4f8..b2246d0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,6 +2,9 @@ using StructHelpers: @batteries, @battery, StructHelpers, @enumbatteries, @enumb const SH = StructHelpers using Test using Aqua +using StaticArrays + +include("showrepr.jl") struct SVanilla a @@ -134,7 +137,7 @@ struct SBare; a; b; end @test_throws MethodError SBatteries(a=1, b=2) s = sprint(show, Skw(a=1, b=2)) - @test occursin("=", s) + @test s == "Skw(a = 1, b = 2)" s = sprint(show, SBatteries(1,2)) @test !occursin("=", s) @@ -720,3 +723,9 @@ end @testset "Aqua" begin Aqua.test_all(StructHelpers) end + +@testset "JET" begin + using JET + result = JET.report_package(StructHelpers) + @test isempty(JET.get_reports(result)) +end diff --git a/test/showrepr.jl b/test/showrepr.jl new file mode 100644 index 0000000..dd82488 --- /dev/null +++ b/test/showrepr.jl @@ -0,0 +1,415 @@ +# Tests for the `showrepr` battery (heuristic short `Base.show` that +# round-trips through the type's constructor). The struct definitions +# below are mostly minimal fixtures designed to exercise one aspect of +# the algorithm; they are wedged in next to the testset that uses them +# so it stays easy to see what is being probed. +# +# This file is `include`d from `runtests.jl` at the appropriate point; +# it shares `runtests.jl`'s `Main`-level scope (so e.g. `StructHelpers`, +# `SH`, `Test`, and `StaticArrays` must already be in scope). + +Base.@kwdef struct SDefaults + a = 1 + b = 2 + c = 3 +end +@batteries SDefaults showrepr=true + +Base.@kwdef struct SDefaultsKw + a = 1 + b = 2 +end +@batteries SDefaultsKw showrepr=true + +struct SExtraCtor + a + b +end +SExtraCtor(a) = SExtraCtor(a, 2) +SExtraCtor() = SExtraCtor(1, 2) +@batteries SExtraCtor showrepr=true + +Base.@kwdef struct SMissingDefault + a = missing + b = 1 +end +@batteries SMissingDefault showrepr=true + +struct SNaNCtor + x::Float64 +end +SNaNCtor() = SNaNCtor(NaN) +@batteries SNaNCtor showrepr=true + +struct SHybrid + a + b + c +end +SHybrid(a, b; c=3) = SHybrid(a, b, c) +@batteries SHybrid showrepr=true + +@testset "showrepr shortest representation" begin + # All defaults: empty constructor wins. + @test sprint(show, SDefaults()) == "SDefaults()" + # One non-default: only that field is shown. + @test sprint(show, SDefaults(b=10)) == "SDefaults(b = 10)" + # Last non-default with defaults preceding it. + @test sprint(show, SDefaults(c=30)) == "SDefaults(c = 30)" + # Multiple non-defaults: round-trip plus a sanity length bound. We do + # not pin the exact rendering — the algorithm may pick the kwarg or + # positional form depending on tie-breaking — but it must be no + # longer than the naive all-positional form. + s = sprint(show, SDefaults(a=10, c=30)) + @test SDefaults(a=10, c=30) == eval(Meta.parse(s)) + @test length(s) <= length("SDefaults(10, 2, 30)") + # All non-default. + s = sprint(show, SDefaults(a=10, b=20, c=30)) + @test SDefaults(a=10, b=20, c=30) == eval(Meta.parse(s)) + + # `Base.@kwdef` synthesizes a kwconstructor that `showrepr` picks up + # without `kwconstructor=true` being passed to `@batteries`. + @test sprint(show, SDefaultsKw()) == "SDefaultsKw()" + s = sprint(show, SDefaultsKw(b=10)) + @test SDefaultsKw(b=10) == eval(Meta.parse(s)) + @test length(s) <= length("SDefaultsKw(1, 10)") + + # When extra positional constructors exist, the shortest one wins + # over the keyword form. + @test sprint(show, SExtraCtor(1, 2)) == "SExtraCtor()" + @test sprint(show, SExtraCtor(7, 2)) == "SExtraCtor(7)" + @test sprint(show, SExtraCtor(7, 8)) == "SExtraCtor(7, 8)" + + # `missing` as a default value is handled correctly: a value of + # `missing` matches the default (via `isequal`), while non-`missing` + # values do not. + @test sprint(show, SMissingDefault()) == "SMissingDefault()" + @test sprint(show, SMissingDefault(a=missing, b=2)) == "SMissingDefault(b = 2)" + s = sprint(show, SMissingDefault(a=42, b=1)) + @test SMissingDefault(a=42, b=1) == eval(Meta.parse(s)) || + isequal(SMissingDefault(a=42, b=1), eval(Meta.parse(s))) + + # `NaN` field values are recognized as recreated even though + # `NaN == NaN` is `false`. + @test sprint(show, SNaNCtor(NaN)) == "SNaNCtor()" + + # Hybrid constructor `SHybrid(a, b; c=3)`: when `c == 3`, the kwarg + # is dropped and the 2-arg form wins. When `c` differs, the 3-arg + # positional inner constructor happens to be shorter than the kwarg + # form, so it wins. Either way the rendering recreates the object. + @test sprint(show, SHybrid(1, 2, 3)) == "SHybrid(1, 2)" + s = sprint(show, SHybrid(1, 2, 9)) + @test SHybrid(1, 2, 9) == eval(Meta.parse(s)) + @test length(s) <= length("SHybrid(1, 2, c = 9)") +end + +# Unsigned fields render shorter as signed decimals when the constructor +# accepts the substitution (here: typed fields convert from `Int`). +struct SUnsigned + a::UInt8 + b::UInt64 +end +@batteries SUnsigned showrepr=true + +@testset "showrepr unsigned shortening" begin + @test sprint(show, SUnsigned(0x07, UInt64(42))) == "SUnsigned(7, 42)" + s = sprint(show, SUnsigned(0xff, UInt64(99))) + @test eval(Meta.parse(s)) == SUnsigned(0xff, UInt64(99)) +end + +# `simple` prefers `Int` over wider signed types whenever the value +# fits, so the substitute matches what a user would normally write. The +# check is value-based, not type-based: `UInt64(42)` becomes `Int(42)`, +# but `typemax(UInt64)` (which does not fit in `Int`) falls back to the +# next-widest signed type. +struct SInt128Field + x::Int128 + y::UInt64 +end +@batteries SInt128Field showrepr=true + +@testset "showrepr prefers Int over wider integer types" begin + @test StructHelpers.simple(UInt8(7)) === Int(7) + @test StructHelpers.simple(UInt16(42)) === Int(42) + @test StructHelpers.simple(UInt32(0)) === Int(0) + @test StructHelpers.simple(UInt64(99)) === Int(99) + @test StructHelpers.simple(Int128(42)) === Int(42) + @test StructHelpers.simple(big(42)) === Int(42) + # Out-of-`Int`-range unsigned: still falls back to a widened signed type. + @test StructHelpers.simple(typemax(UInt64)) isa Int128 + # Numeric forms collapse via the universal `Int(v)` path. + @test StructHelpers.simple(2 + 0im) === Int(2) + @test StructHelpers.simple(2//1) === Int(2) + @test StructHelpers.simple(2.0) === Int(2) + # `Rational`/`Complex` wrappers around an out-of-`Int`-range value still + # drop the wrapper by recursing through `simple` on the inner numerator + # / real part (which itself collapses to a widened signed type). + @test StructHelpers.simple(Complex(typemax(UInt64))) isa Int128 + @test StructHelpers.simple(typemax(UInt64) // 1) isa Int128 + # `-0.0` must preserve its sign bit (would silently become `0` under `Int`). + @test StructHelpers.simple(-0.0) === nothing + # Non-numeric values still return `nothing`. + @test StructHelpers.simple("foo") === nothing + # `Number` subtypes that don't define an `Int` conversion (the call + # throws `MethodError` rather than `InexactError`) are tolerated and + # fall through to `nothing` rather than propagating the error. This + # is what allows `simple` to be safely called on user-defined + # numeric wrappers like `Unitful.Quantity`. + struct UnConvertibleNum <: Number end + @test StructHelpers.simple(UnConvertibleNum()) === nothing +end + +# Whole-valued floats render as integer literals when the constructor +# accepts them; non-integer, non-finite, and `-0.0` values are left alone. +struct SFloatFields + x::Float64 + y::Float64 +end +@batteries SFloatFields showrepr=true + +@testset "showrepr float shortening" begin + @test sprint(show, SFloatFields(2.0, 3.0)) == "SFloatFields(2, 3)" + @test sprint(show, SFloatFields(2.5, 3.0)) == "SFloatFields(2.5, 3)" + @test sprint(show, SFloatFields(Inf, 1.0)) == "SFloatFields(Inf, 1)" + # `NaN` is non-finite and must not be shortened to an integer. + s = sprint(show, SFloatFields(NaN, 2.0)) + @test occursin("NaN", s) + @test isequal(eval(Meta.parse(s)), SFloatFields(NaN, 2.0)) + # -0.0 must round-trip exactly under isequal (the user may rely on the sign bit). + s = sprint(show, SFloatFields(-0.0, 1.0)) + @test occursin("-0.0", s) + @test isequal(eval(Meta.parse(s)), SFloatFields(-0.0, 1.0)) +end + +# Rationals with denominator 1 render as bare integers when the constructor +# accepts them; non-unit denominators are left alone. +struct SRatFields + a::Rational{Int} + b::Rational{Int} +end +@batteries SRatFields showrepr=true + +@testset "showrepr rational shortening" begin + @test sprint(show, SRatFields(2//1, 3//4)) == "SRatFields(2, 3//4)" + @test sprint(show, SRatFields(0//1, 5//1)) == "SRatFields(0, 5)" + s = sprint(show, SRatFields(7//1, -2//3)) + @test eval(Meta.parse(s)) == SRatFields(7//1, -2//3) +end + +# Complex numbers with zero imaginary part render as bare reals when the +# constructor accepts them; non-zero imaginary parts are left alone. +struct SCplxFields + a::Complex{Int} + b::Complex{Int} +end +@batteries SCplxFields showrepr=true + +@testset "showrepr complex shortening" begin + @test sprint(show, SCplxFields(2 + 0im, 3 + 4im)) == "SCplxFields(2, 3 + 4im)" + @test sprint(show, SCplxFields(0 + 0im, 1 + 0im)) == "SCplxFields(0, 1)" + s = sprint(show, SCplxFields(7 + 0im, -2 + 5im)) + @test eval(Meta.parse(s)) == SCplxFields(7 + 0im, -2 + 5im) +end + +# Uniform `AbstractVector` fields render as `fill(v, n)` (for `isbits` +# elements) or `[v for _ = 1:n]` (for non-`isbits`, to avoid aliasing) +# when shorter than the literal AND when the constructor accepts the +# substitute. The substitute is a `Vector{eltype(v)}`, so strictly-typed +# fields (e.g. an `NTuple`-only constructor) fall back to the default +# `repr`. +struct SVecField + a::Vector{Int} + b::Vector{Float64} +end +@batteries SVecField showrepr=true + +Base.@kwdef struct SVecFieldKwDefaults + a::Vector{Int} = [0, 0, 0, 0, 0] + b::Vector{Int} = [1, 2, 3] +end +@batteries SVecFieldKwDefaults showrepr=true + +# A struct whose constructor strictly types the field. The `fill` +# substitute is a `Vector`, which the `NTuple`-only constructor rejects, +# so the literal rendering is preserved. +struct STupleField + a::NTuple{3,Int} +end +@batteries STupleField showrepr=true + +@testset "showrepr vector fill compression" begin + # Long uniform vector → `fill(...)` is shorter than the literal. + @test sprint(show, SVecField([1,1,1,1,1], [2.0,3.0])) == + "SVecField(fill(1, 5), [2.0, 3.0])" + # Both fields uniform. + @test sprint(show, SVecField([7,7,7,7], [0.0,0.0,0.0,0.0])) == + "SVecField(fill(7, 4), fill(0.0, 4))" + # Short uniform vector ([1,1,1] vs fill(1,3)) → literal already + # shorter, no compression. + @test sprint(show, SVecField([1,1,1], [1.0])) == + "SVecField([1, 1, 1], [1.0])" + # Non-uniform → no compression. + @test sprint(show, SVecField([1,2,3,4,5], [0.0,0.0,0.0,0.0])) == + "SVecField([1, 2, 3, 4, 5], fill(0.0, 4))" + # Empty / single element → no compression. + @test sprint(show, SVecField(Int[], [3.0])) == + "SVecField(Int64[], [3.0])" + # Round-trip every case: parsing the rendering must produce a value + # equal to the original under `repr_eq`. + for (a, b) in ((Int[1,1,1,1,1], Float64[2.0,3.0]), + (Int[7,7,7,7], Float64[0.0,0.0,0.0,0.0]), + (Int[1,1,1], Float64[1.0]), + (Int[1,2,3,4,5], Float64[0.0,0.0,0.0,0.0])) + o = SVecField(a, b) + s = sprint(show, o) + @test eval(Meta.parse(s)) == o + end + + # Default-omission still works when the kwarg's default also gets + # compressed: `SVecFieldKwDefaults()` has both defaults so renders + # without arguments. + @test sprint(show, SVecFieldKwDefaults()) == "SVecFieldKwDefaults()" + # Overriding `a` with another long uniform vector still uses `fill`. + @test sprint(show, SVecFieldKwDefaults(a=[9,9,9,9,9,9])) == + "SVecFieldKwDefaults(a = fill(9, 6))" + + # Strictly-typed field (NTuple) won't accept a `Vector` substitute, + # so the default rendering is preserved. + @test sprint(show, STupleField((5,5,5))) == "STupleField((5, 5, 5))" + + # Run-length compression with a heterogeneous vector: the long zero + # run is splatted while distinct elements stay literal. + @test sprint(show, SVecField([1,17,0,0,0,0,0,0,0,0], [1.0])) == + "SVecField([1, 17, fill(0, 8)...], [1.0])" + let o = SVecField([1,17,0,0,0,0,0,0,0,0], [1.0]) + @test eval(Meta.parse(sprint(show, o))) == o + end + # Multiple long runs are each splatted. + @test sprint(show, SVecField([1,1,1,1,1,1,2,2,2,2,2,2], [1.0])) == + "SVecField([fill(1, 6)..., fill(2, 6)...], [1.0])" + # Short runs aren't worth splatting → falls back to plain literal. + @test sprint(show, SVecField([1,17,0,0,0], [1.0])) == + "SVecField([1, 17, 0, 0, 0], [1.0])" +end + +# Fields whose concrete type's `constructorof` accepts positional varargs +# (e.g. `SVector`, `MVector`) render as `Tc(elems...)` so the printed form +# round-trips back to that concrete type. Note: `constructorof(SVector{N,T})` +# is `SVector` (without the size parameter), so the printed form drops the +# static size — round-trip recovers it via type inference from the arg count. +struct SStaticVecField + v::SVector{16, Int} +end +@batteries SStaticVecField showrepr=true + +@testset "showrepr static-vector fill compression" begin + # On Julia 1.6, `constructorof(SVector{N,T}) === SArray` and + # `SArray(args...)` throws `DimensionMismatch`. `compact` then falls + # back to a bracket literal, which the field's `convert` accepts — + # round-trip works on all versions, but the printed name differs, so + # the string-shape assertions below are gated on Julia >= 1.7. + if VERSION >= v"1.7" + # Uniform → splat into the static constructor. + @test sprint(show, SStaticVecField(SVector(ntuple(_->0, Val(16))...))) == + "SStaticVecField(SVector(fill(0, 16)...))" + # Mixed leading element + uniform tail. + @test sprint(show, SStaticVecField(SVector(ntuple(i->i==1 ? 7 : 0, Val(16))...))) == + "SStaticVecField(SVector(7, fill(0, 15)...))" + end + # Round-trip the compressed forms (works on all versions). + for v in (SVector(ntuple(_->0, Val(16))...), + SVector(ntuple(i->i==1 ? 7 : 0, Val(16))...)) + o = SStaticVecField(v) + @test eval(Meta.parse(sprint(show, o))) == o + end + # When the bracket literal is genuinely shorter than the wrapped form + # (small array, small elements), `compact` rejects the wrapping and + # falls back to plain `show` — the field's `convert` still accepts a + # `Vector`, so this round-trips even though no `SVector(...)` appears. + @test sprint(show, SStaticVecField(SVector(1, 2, 3, 4, 5, 6, 7, 8, + 9,10,11,12,13,14,15,16))) == + "SStaticVecField([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])" +end + +# Mutable element types: re-evaluating the comprehension must produce +# distinct instances (no aliasing). This is the reason `compact` +# uses a comprehension rather than `fill`. +mutable struct MutCell + x::Int +end +Base.:(==)(a::MutCell, b::MutCell) = a.x == b.x +Base.show(io::IO, c::MutCell) = print(io, "MutCell(", c.x, ")") + +struct SMutVec + cells::Vector{MutCell} +end +@batteries SMutVec showrepr=true + +@testset "showrepr vector comprehension preserves distinct instances" begin + o = SMutVec([MutCell(7), MutCell(7), MutCell(7), MutCell(7), MutCell(7)]) + s = sprint(show, o) + @test s == "SMutVec([MutCell(7) for _ = 1:5])" + o2 = eval(Meta.parse(s)) + @test o2.cells[1] !== o2.cells[2] # distinct instances, no aliasing + @test o2.cells == o.cells +end + +# `kwshow` and `showrepr` both target `Base.show` and are mutually exclusive. +struct SBothShow; a; end +@testset "showrepr/kwshow mutual exclusion" begin + if VERSION >= v"1.8" + @test_throws "mutually exclusive" @macroexpand @batteries SBothShow kwshow=true showrepr=true + else + @test_throws Exception @macroexpand @batteries SBothShow kwshow=true showrepr=true + end +end + +# When no constructor recreates the object, `showrepr` falls back to a +# named-tuple-style rendering. We trigger this with a struct whose only +# user-defined constructor unconditionally errors and which has a single +# typed field so that the auto-generated inner constructor also fails on +# the held value (we install one via `Core.setfield!` on a mutable proxy). +mutable struct SFallback + a::Int + SFallback() = error("rejected") +end +let s = ccall(:jl_new_struct_uninit, Any, (Any,), SFallback)::SFallback + s.a = 7 + global _sfallback_inst = s +end +@batteries SFallback showrepr=true selfconstructor=false eq=false isequal=false hash=false + +@testset "showrepr fallback when no constructor recreates" begin + s = sprint(show, _sfallback_inst) + @test occursin("SFallback", s) + @test occursin("a", s) && occursin("7", s) +end + +# `mutable struct` whose `==` defaults to `===`: a fresh reconstruction is +# never `==` to the original, so without field-level comparison every +# constructor candidate (and every default-elision attempt) would be +# rejected. Verify defaults still get elided here. +Base.@kwdef mutable struct SMutNoEq + a::Int = 0 + b::Int = 0 +end +@batteries SMutNoEq showrepr=true eq=false isequal=false hash=false + +@testset "showrepr handles mutable structs without ==" begin + @test sprint(show, SMutNoEq()) == "SMutNoEq()" + @test sprint(show, SMutNoEq(b = 7)) == "SMutNoEq(0, 7)" + @test sprint(show, SMutNoEq(a = 1, b = 2)) == "SMutNoEq(1, 2)" +end + +# Vararg constructors must be skipped (we don't know how many fields to splat). +struct SVararg + a + b +end +SVararg(args...) = SVararg(args[1], args[2]) +@batteries SVararg showrepr=true + +@testset "showrepr skips vararg constructors" begin + s = sprint(show, SVararg(1, 2)) + @test SVararg(1, 2) == eval(Meta.parse(s)) +end