From 968f10f17fa8a0393ef366c14845455821250f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Sun, 17 May 2026 06:17:35 +0200 Subject: [PATCH 1/9] Add JET --- Project.toml | 4 +++- test/runtests.jl | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 30dc4c4..ca378a2 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,7 @@ ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" [compat] Aqua = "0.8, 1" ConstructionBase = "1.3" +JET = "0.9, 0.10, 0.11" 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 +20,9 @@ julia = "1.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "Test", "StructTypes"] +test = ["Aqua", "JET", "Test", "StructTypes"] diff --git a/test/runtests.jl b/test/runtests.jl index 35ab4f8..d0e0500 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -720,3 +720,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 From 91f6363cc9b65f0af459b030920d2ae2ed85ed42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Sun, 17 May 2026 06:26:29 +0200 Subject: [PATCH 2/9] CI: drop JET on Julia 1.6 and nightly JET's registry compat currently requires 1.7 <= Julia <= 1.12, so Pkg cannot resolve JET on Julia 1.6 and nightly. To get a green CI, strip JET from Project.toml and runtests.jl on the Julia versions not supported by JET. This means only the 'latest stable' job actually has JET coverage on CI. --- .github/workflows/CI.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 98042a0c68755bb5d662928898c356733728ea2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Sun, 17 May 2026 06:54:25 +0200 Subject: [PATCH 3/9] Fix JET 0.11 warning: always set `fieldnames` JET 0.11's flow analysis can't prove that the two reads of `fieldnames` (under `if nt.getproperties` and `if nt.kwconstructor`) are reachable only when `need_fieldnames` (the assignment guard) is true. Assign `fieldnames` unconditionally with a cheap fallback so the binding is always defined. Co-authored-by: Copilot --- src/StructHelpers.jl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 3031bd8..8837360 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -350,9 +350,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)) From 271d2d2b9836d334b0ea438fd6e3feec33f217d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Thu, 7 May 2026 04:41:51 +0200 Subject: [PATCH 4/9] Add @battery / @enumbattery: opt-in counterparts to @batteries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @batteries derives a curated set of methods by default; @battery does the opposite — every default flips to false, so only the explicitly listed batteries are derived. The bare-flag shorthand from the previous PR makes call sites read as a checklist: @battery T kwconstructor # only the kwarg constructor @battery T eq isequal hash # only structural ==/isequal/hash @battery T hash typesalt=0xab # only stable hash This addresses the side-effect concern with subset use cases of @batteries (\`@batteries T showrepr=true\` silently also defines ==, hash, isequal, getproperties, constructorof, selfconstructor): users who want exactly one or two batteries can now ask for just those, with no surprise additions now or additional surprises in the future when the defaults change. @enumbattery is the analogous opt-in form of @enumbatteries. Implementation factors the macro bodies into shared codegen helpers that take the base named tuple. The 'all-false' bases are derived programmatically from the existing DEFAULTS so they stay in sync if a new option is added later. Co-authored-by: Copilot --- src/StructHelpers.jl | 11 +++++++++++ test/runtests.jl | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 8837360..1247534 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -302,6 +302,7 @@ end @battery S hash typesalt=0xab # only define hash, with stable typesalt ``` +<<<<<<< HEAD `@battery` accepts the same `NamedTuple` config splatting as [`@batteries`](@ref): @@ -311,6 +312,8 @@ const minimal = (kwshow=true, kwconstructor=true) @battery T minimal eq=true # the two from `minimal` plus structural == ``` +======= +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) Supported options are the same as for [`@batteries`](@ref); only the defaults differ (every Bool defaults to `false`, `typesalt` to `nothing`). """ @@ -323,7 +326,11 @@ end # (everything off). The same validation and emission logic is used in both # cases. function def_batteries(__module__, T, kw, base) +<<<<<<< HEAD nt = parse_all_macro_kw(kw, __module__, propertynames(BATTERIES_DEFAULTS)) +======= + nt = parse_all_macro_kw(kw) +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) for (pname, val) in pairs(nt) if !(pname in propertynames(BATTERIES_DEFAULTS)) error(""" @@ -713,7 +720,11 @@ end # `ENUM_BATTERIES_NONE` (everything off, except the always-on # `enum_from_*` / `*_from_enum` methods). function def_enumbatteries(__module__, T, kw, base) +<<<<<<< HEAD nt = parse_all_macro_kw(kw, __module__, propertynames(ENUM_BATTERIES_DEFAULTS)) +======= + nt = parse_all_macro_kw(kw) +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) for (pname, val) in pairs(nt) if !(pname in propertynames(ENUM_BATTERIES_DEFAULTS)) error(""" diff --git a/test/runtests.jl b/test/runtests.jl index d0e0500..84c8305 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -195,6 +195,7 @@ struct SBare; a; b; end @test NoSelfCtor(NoSelfCtor(1)).a === NoSelfCtor(1) end +<<<<<<< HEAD @testset "default_keywords" begin @test SH.default_keywords(SBatteries) === NamedTuple() @test SH.default_keywords(WithDefaults) === (a = 1, b = 2) @@ -249,6 +250,8 @@ end @test COUNTED_DEFAULTS_CALLS[] == 1 end +======= +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) # `@battery T ...`: opt-in variant where every default is `false`. Only # the listed batteries are derived; nothing else. struct BatOnlyKwconstructor; a; b; end @@ -296,14 +299,19 @@ struct BatNothing; a; end # `@battery` rejects unknown keywords just like `@batteries`. if VERSION >= v"1.8" +<<<<<<< HEAD # Bare symbol that is neither a flag nor bound to a NamedTuple # triggers the config-eval path and reports a "Bad argument" # error with the underlying UndefVarError. @test_throws "Bad argument" @macroexpand @battery BatNothing nonsense +======= + @test_throws "Unsupported keyword" @macroexpand @battery BatNothing nonsense +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) @test_throws "Bad keyword argument value" @macroexpand @battery BatNothing kwshow="true" end end +<<<<<<< HEAD # `NamedTuple`-config splatting: any macro argument that is neither a # flag nor `name=value` is evaluated in the calling module and its # `pairs(...)` are spliced in. Later args override earlier ones. @@ -450,6 +458,8 @@ struct CfgExplicit; a; end end end +======= +>>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles @enum Color Red Blue Green From bfd0633e3d96910e78637059c6531a513982812f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Sat, 9 May 2026 20:01:17 +0200 Subject: [PATCH 5/9] Add `showrepr` battery: heuristic short, recreatable `Base.show` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #12. This is the real PR I wanted to do. All the others are basically there to enable this one. Introduces a new `@batteries` / `@battery` flag `showrepr=true` that overloads `Base.show` to print a heuristically short constructor call which round-trips through `eval`, as an alternative to the existing `kwshow`. The two are mutually exclusive (both target `Base.show`). I originally wanted to just extend `kwshow`. However, as the redundancy reduction should also be available if there is no keyword constructor and/or calls are short when using a parametric constructor, this would have lead to broken backwards-compatibility requiring a major version bump and user changes. But as both approaches have different advantages, it's probably a good thing to have both in the future. Highlights: * Probes every constructor of the type — positional, keyword, and hybrid (including the pair synthesized by `Base.@kwdef`) — and picks the shortest call whose result is `repr_eq` to the original. Trailing fields whose values match a default are omitted. * Per-field literal shortening where the constructor still accepts the substitute: `0x000000000000002a` → `42`, whole-valued floats drop the `.0` suffix, `2//1` → `2`, `2 + 0im` → `2`. * Uniform / run-length-compressed vectors render as `fill(v, n)` (or `[v for _=1:n]` for non-`isbits` elements, to avoid aliasing on re-eval); heterogeneous vectors get per-run RLE only when it actually shortens the literal. * Round-trip safety is the gating criterion throughout: every substitution is verified by reconstructing through the constructor and comparing via `repr_eq` (which accepts both `==` and `isequal` to handle `NaN`, `-0.0`, and custom equality). * Falls back to a non-executable `T(field = value, …)` rendering when no constructor recreates the object. `kwshow` keeps its existing fixed `T(f1 = v1, …)` shape and stability guarantees; `showrepr`'s output is heuristic and not pinned across minor releases. The README contrasts the two. This builds on #18 and #19 which should be merged first. Co-authored-by: Copilot --- Project.toml | 3 +- README.md | 28 +++ src/StructHelpers.jl | 331 ++++++++++++++++++++++++++++++++-- test/runtests.jl | 412 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 751 insertions(+), 23 deletions(-) diff --git a/Project.toml b/Project.toml index ca378a2..cb720e9 100644 --- a/Project.toml +++ b/Project.toml @@ -21,8 +21,9 @@ 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", "JET", "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 1247534..8bede53 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -112,6 +112,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, "(") @@ -133,6 +143,307 @@ function kwshow(io::IO, o) print(io, ")") end +""" + 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 + +""" + 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) + relevant_kws = kws ∩ 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. + recreates(kws) = try + matches(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(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)) + # Vararg-from-elements? E.g. `SVector{4}(1, 2, 3, 4)`, `Tuple(...)`. If + # so, prefer `Tc(...)` so the printed form preserves the concrete type + # (notably the static size of `SVector`). `Vector`'s constructor rejects + # this signature, so we fall back to a bracket literal there. + str = if hasmethod(Tc, NTuple{length(v),Any}) + kind === :uniform ? "$Tc($pieces...)" : "$Tc($pieces)" + else + kind === :uniform ? pieces : "[$pieces]" + 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 + function def_getproperties(T, propertynames) body = Expr(:tuple) for pname in propertynames @@ -173,6 +484,7 @@ const BATTERIES_DEFAULTS = ( kwconstructor = false, selfconstructor = true, kwshow = false, + showrepr = false, getproperties = true , constructorof = true , typesalt = nothing, @@ -186,6 +498,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", @@ -302,7 +615,6 @@ end @battery S hash typesalt=0xab # only define hash, with stable typesalt ``` -<<<<<<< HEAD `@battery` accepts the same `NamedTuple` config splatting as [`@batteries`](@ref): @@ -312,8 +624,6 @@ const minimal = (kwshow=true, kwconstructor=true) @battery T minimal eq=true # the two from `minimal` plus structural == ``` -======= ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) Supported options are the same as for [`@batteries`](@ref); only the defaults differ (every Bool defaults to `false`, `typesalt` to `nothing`). """ @@ -326,11 +636,7 @@ end # (everything off). The same validation and emission logic is used in both # cases. function def_batteries(__module__, T, kw, base) -<<<<<<< HEAD nt = parse_all_macro_kw(kw, __module__, propertynames(BATTERIES_DEFAULTS)) -======= - nt = parse_all_macro_kw(kw) ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) for (pname, val) in pairs(nt) if !(pname in propertynames(BATTERIES_DEFAULTS)) error(""" @@ -385,10 +691,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) @@ -720,11 +1033,7 @@ end # `ENUM_BATTERIES_NONE` (everything off, except the always-on # `enum_from_*` / `*_from_enum` methods). function def_enumbatteries(__module__, T, kw, base) -<<<<<<< HEAD nt = parse_all_macro_kw(kw, __module__, propertynames(ENUM_BATTERIES_DEFAULTS)) -======= - nt = parse_all_macro_kw(kw) ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) for (pname, val) in pairs(nt) if !(pname in propertynames(ENUM_BATTERIES_DEFAULTS)) error(""" diff --git a/test/runtests.jl b/test/runtests.jl index 84c8305..6b95128 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,6 +2,7 @@ using StructHelpers: @batteries, @battery, StructHelpers, @enumbatteries, @enumb const SH = StructHelpers using Test using Aqua +using StaticArrays struct SVanilla a @@ -134,7 +135,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) @@ -195,7 +196,6 @@ struct SBare; a; b; end @test NoSelfCtor(NoSelfCtor(1)).a === NoSelfCtor(1) end -<<<<<<< HEAD @testset "default_keywords" begin @test SH.default_keywords(SBatteries) === NamedTuple() @test SH.default_keywords(WithDefaults) === (a = 1, b = 2) @@ -250,8 +250,6 @@ end @test COUNTED_DEFAULTS_CALLS[] == 1 end -======= ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) # `@battery T ...`: opt-in variant where every default is `false`. Only # the listed batteries are derived; nothing else. struct BatOnlyKwconstructor; a; b; end @@ -299,19 +297,14 @@ struct BatNothing; a; end # `@battery` rejects unknown keywords just like `@batteries`. if VERSION >= v"1.8" -<<<<<<< HEAD # Bare symbol that is neither a flag nor bound to a NamedTuple # triggers the config-eval path and reports a "Bad argument" # error with the underlying UndefVarError. @test_throws "Bad argument" @macroexpand @battery BatNothing nonsense -======= - @test_throws "Unsupported keyword" @macroexpand @battery BatNothing nonsense ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) @test_throws "Bad keyword argument value" @macroexpand @battery BatNothing kwshow="true" end end -<<<<<<< HEAD # `NamedTuple`-config splatting: any macro argument that is neither a # flag nor `name=value` is evaluated in the calling module and its # `pairs(...)` are spliced in. Later args override earlier ones. @@ -458,8 +451,405 @@ struct CfgExplicit; a; end end end -======= ->>>>>>> 21ccef6 (Add @battery / @enumbattery: opt-in counterparts to @batteries) +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 + # 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)...))" + # Round-trip the compressed forms. + 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 type still coerces, 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 + @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles @enum Color Red Blue Green From 86269b5bb0902b6eed677780a0cfa99a5906b51d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Sun, 10 May 2026 06:26:24 +0200 Subject: [PATCH 6/9] Fix Julia 1.6 issues found by CI Co-authored-by: Copilot --- src/StructHelpers.jl | 20 ++++++++++++-------- test/runtests.jl | 25 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 8bede53..b175b79 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -416,14 +416,18 @@ function compact(v::AbstractVector) cp === nothing && return nothing pieces, kind = cp Tc = constructorof(typeof(v)) - # Vararg-from-elements? E.g. `SVector{4}(1, 2, 3, 4)`, `Tuple(...)`. If - # so, prefer `Tc(...)` so the printed form preserves the concrete type - # (notably the static size of `SVector`). `Vector`'s constructor rejects - # this signature, so we fall back to a bracket literal there. - str = if hasmethod(Tc, NTuple{length(v),Any}) - kind === :uniform ? "$Tc($pieces...)" : "$Tc($pieces)" - else - kind === :uniform ? pieces : "[$pieces]" + # 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)) diff --git a/test/runtests.jl b/test/runtests.jl index 6b95128..e729d4e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -746,13 +746,20 @@ end @batteries SStaticVecField showrepr=true @testset "showrepr static-vector fill compression" begin - # 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)...))" - # Round-trip the compressed forms. + # 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) @@ -760,8 +767,8 @@ end 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 type still coerces, so this - # round-trips even though no `SVector(...)` appears. + # 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])" From 20e866731e64b050a719815332527831a9467325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Wed, 13 May 2026 07:30:36 +0200 Subject: [PATCH 7/9] Make def_batteries JET-clean: always bind fieldnames JET (via report_package on downstream packages) flagged \`local variable fieldnames may be undefined\` at the call sites that pass \`fieldnames\` into \`def_getproperties\` and \`def_kwconstructor\`. The previous code only bound \`fieldnames\` inside \`if need_fieldnames\`, and JET could not correlate the guard with the later uses. Bind it unconditionally (with \`()\` as a harmless fallback when not needed) so the variable is always in scope. Co-authored-by: Copilot --- src/StructHelpers.jl | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index b175b79..47e449c 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -159,6 +159,15 @@ 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 @@ -209,7 +218,11 @@ function constructor_repr(o) # `Base.kwarg_decl` is internal, but the only known way to # introspect a method's kwargs. kws = Base.kwarg_decl(m) - relevant_kws = kws ∩ fnames + # 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 @@ -218,9 +231,10 @@ function constructor_repr(o) 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. + # ...); 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(T(pos_vals...; (k => getfield(o, k) for k in kws)...)) + matches(invoke_ctor(T, pos_vals, (k => getfield(o, k) for k in kws))) catch false end @@ -238,7 +252,7 @@ function constructor_repr(o) # accepting only when `T(...)` still recreates `o`. kw_vals = Dict{Symbol,Any}(k => getfield(o, k) for k in keep_kws) trycall() = try - matches(T(pos_vals...; (k => kw_vals[k] for k in keep_kws)...)) + matches(invoke_ctor(T, pos_vals, (k => kw_vals[k] for k in keep_kws))) catch false end From 73d24ced8823dfcc1f7736747df224565336cd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Mon, 18 May 2026 06:15:39 +0200 Subject: [PATCH 8/9] Make Aqua happy by adding a compat entry for a test dependency --- Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.toml b/Project.toml index cb720e9..0bba75f 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,7 @@ ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" 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 From 9c65519f47202d914c070b56c2bcd7554363f847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Mon, 18 May 2026 06:16:09 +0200 Subject: [PATCH 9/9] Move `showrepr` logic into separate files --- src/StructHelpers.jl | 321 +-------------------------------- src/showrepr.jl | 329 ++++++++++++++++++++++++++++++++++ test/runtests.jl | 408 +----------------------------------------- test/showrepr.jl | 415 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 748 insertions(+), 725 deletions(-) create mode 100644 src/showrepr.jl create mode 100644 test/showrepr.jl diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 47e449c..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 @@ -143,325 +145,6 @@ function kwshow(io::IO, o) print(io, ")") end -""" - 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 - function def_getproperties(T, propertynames) body = Expr(:tuple) for pname in propertynames 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 e729d4e..b2246d0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,6 +4,8 @@ using Test using Aqua using StaticArrays +include("showrepr.jl") + struct SVanilla a b @@ -451,412 +453,6 @@ struct CfgExplicit; a; end end end -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 - @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles @enum Color Red Blue Green 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