From a500064f7de85c56f6ca9ee8a12a33dfc958c94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Wed, 6 May 2026 07:41:45 +0200 Subject: [PATCH 1/6] Add bare-flag sugar to @batteries / @enumbatteries A bare symbol is now accepted as shorthand for \`flag=true\`: @batteries T kwconstructor # equivalent to kwconstructor=true @batteries T kwconstructor kwshow hash=false # mixable Also unblocks the cleaner subset-mode call shape we want for an upcoming \`@battery\` macro that derives a single hand-picked battery. --- src/StructHelpers.jl | 8 ++++++++ test/runtests.jl | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 3929627..929ac4e 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -225,6 +225,7 @@ end @batteries S @batteries S hash=false # don't overload `Base.hash` @batteries S kwconstructor=true # add a keyword constructor +@batteries S kwconstructor # bare symbol is shorthand for `kwconstructor=true` ``` Supported options and defaults are: @@ -355,6 +356,12 @@ function error_parse_macro_kw(kw; comment=nothing) error(msg) end function parse_single_macro_kw(kw) + # Bare-symbol shorthand: `flag` is sugar for `flag=true`. Lets users write + # `@batteries T kwconstructor` instead of `@batteries T kwconstructor=true`. + # The resulting `flag => true` is then validated by the macro itself, so + # bare symbols that don't name a Bool option (e.g. `typesalt`) still + # produce the standard "Bad keyword argument value" error. + kw isa Symbol && return (kw => true) Meta.isexpr(kw, Symbol("=")) || error_parse_macro_kw(kw) length(kw.args) == 2 || error_parse_macro_kw(kw) key, val = kw.args @@ -519,6 +526,7 @@ Automatically derive several methods for Enum type `T`. @enumbatteries Color @enumbatteries Color hash=false # don't overload `Base.hash` @enumbatteries Color symbol_conversion=true # allow convert(Color, :Blue), Color(:Blue), convert(Symbol, Blue), Symbol(Blue) +@enumbatteries Color symbol_conversion # bare symbol is shorthand for `symbol_conversion=true` ``` Supported options and defaults are: diff --git a/test/runtests.jl b/test/runtests.jl index 3b3f544..1711df7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -94,6 +94,10 @@ function SH.default_keywords(::Type{CountedDefaults}) (a = 1, b = 2) end +# Bare-flag sugar: `flag` ≡ `flag=true`, mixable with `flag=value`. +struct SBare; a; b; end +@batteries SBare kwconstructor kwshow hash=false + @testset "@batteries" begin @test SBatteries(1,2) == SBatteries(1,2) @test SBatteries(1,[]) == SBatteries(1,[]) @@ -141,13 +145,22 @@ end if VERSION >= v"1.8" @test_throws "Bad keyword argument value:" @macroexpand @batteries SErrors kwconstructor="true" @test_throws "Unsupported keyword" @macroexpand @batteries SErrors kwconstructor=true nonsense=true - @test_throws "Expected a keyword argument of the form name = value" @macroexpand @batteries SErrors nonsense + # Bare symbol is now sugar for `=true`, so an unknown bare flag fails + # as an unsupported keyword rather than as a parse error. + @test_throws "Unsupported keyword" @macroexpand @batteries SErrors nonsense else @test_throws Exception @macroexpand @batteries SErrors kwconstructor="true" @test_throws Exception @macroexpand @batteries SErrors kwconstructor=true nonsense=true @test_throws Exception @macroexpand @batteries SErrors nonsense end + @testset "bare-flag sugar" begin + # Bare flag works as a substitute for `=true` and is freely mixable + # with explicit `=` assignments. + @test SBare(a=1, b=2) == SBare(1, 2) # kwconstructor enabled + @test sprint(show, SBare(1, 2)) == "SBare(a = 1, b = 2)" # kwshow enabled + end + @testset "typesalt" begin @test hash(Salt1()) === hash(Salt1b()) From 4c03fe9da92b48c00ce807bf6624e6b52a583b24 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 2/6] 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 | 103 ++++++++++++++++++++++++++++++++++++++++--- test/runtests.jl | 81 +++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 929ac4e..241623c 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -1,7 +1,9 @@ module StructHelpers export @batteries +export @battery export @enumbatteries +export @enumbattery import ConstructionBase: getproperties, constructorof, setproperties @@ -209,6 +211,16 @@ end const BATTERIES_ALLOWED_KW = keys(BATTERIES_DEFAULTS) +# Same shape as `BATTERIES_DEFAULTS`, but with every Bool flipped to `false` +# and `typesalt` left at `nothing`. Used as the base nametuple for the +# `@battery` macro, where the user opts in to each desired battery +# explicitly. Derived programmatically so it stays in sync if +# `BATTERIES_DEFAULTS` ever gains a new option. +const BATTERIES_NONE = (; + (k => (v isa Bool ? false : v) for (k, v) in pairs(BATTERIES_DEFAULTS))... +) +@assert keys(BATTERIES_NONE) == keys(BATTERIES_DEFAULTS) + """ @batteries T [options] @@ -232,9 +244,46 @@ Supported options and defaults are: $(doc_batteries_options()) -See also [`hash_eq_as`](@ref) +See also [`@battery`](@ref) for an opt-in variant that derives only the +listed batteries instead of starting from the defaults, and +[`hash_eq_as`](@ref). """ macro batteries(T, kw...) + def_batteries(__module__, T, kw, BATTERIES_DEFAULTS) |> esc +end + +""" + + @battery T [options] + +Like [`@batteries`](@ref) but with every default flipped to `false`: only +the batteries the user lists explicitly are derived. The bare-flag +shorthand makes call sites read as a checklist of behaviors to install. + +# Example +```julia +struct S + a + b +end + +@battery S kwconstructor # only define the keyword constructor +@battery S eq isequal hash # only structural ==, isequal, hash +@battery S hash typesalt=0xab # only define hash, with stable typesalt +``` + +Supported options are the same as for [`@batteries`](@ref); only the +defaults differ (every Bool defaults to `false`, `typesalt` to `nothing`). +""" +macro battery(T, kw...) + def_batteries(__module__, T, kw, BATTERIES_NONE) |> esc +end + +# Shared codegen for `@batteries` / `@battery`. The two macros differ only +# in `base`: `BATTERIES_DEFAULTS` (most batteries on) vs `BATTERIES_NONE` +# (everything off). The same validation and emission logic is used in both +# cases. +function def_batteries(__module__, T, kw, base) nt = parse_all_macro_kw(kw) for (pname, val) in pairs(nt) if !(pname in propertynames(BATTERIES_DEFAULTS)) @@ -258,7 +307,7 @@ macro batteries(T, kw...) """) end end - nt = merge(BATTERIES_DEFAULTS, nt) + nt = merge(base, nt) ret = quote end need_fieldnames = nt.kwconstructor || nt.getproperties @@ -317,7 +366,7 @@ macro batteries(T, kw...) push!(ret.args, def) end push!(ret.args, def_has_batteries(T)) - return esc(ret) + return ret end # `$Type` (and friends below) interpolate the actual Core.Type value @@ -514,6 +563,15 @@ end const ENUM_BATTERIES_ALLOWED_KW = keys(ENUM_BATTERIES_DEFAULTS) +# Same shape as `ENUM_BATTERIES_DEFAULTS`, but with every Bool flipped to +# `false` and `typesalt` left at `nothing`. Used as the base nametuple for +# the `@enumbattery` macro. Derived programmatically so it stays in sync +# if `ENUM_BATTERIES_DEFAULTS` ever gains a new option. +const ENUM_BATTERIES_NONE = (; + (k => (v isa Bool ? false : v) for (k, v) in pairs(ENUM_BATTERIES_DEFAULTS))... +) +@assert keys(ENUM_BATTERIES_NONE) == keys(ENUM_BATTERIES_DEFAULTS) + """ @enumbatteries T [options] @@ -532,8 +590,40 @@ Automatically derive several methods for Enum type `T`. Supported options and defaults are: $(doc_enum_batteries_options()) + +See also [`@enumbattery`](@ref) for an opt-in variant that derives only +the listed batteries instead of starting from the defaults. """ macro enumbatteries(T, kw...) + def_enumbatteries(__module__, T, kw, ENUM_BATTERIES_DEFAULTS) |> esc +end + +""" + + @enumbattery T [options] + +Like [`@enumbatteries`](@ref) but with every default flipped to `false`: +only the batteries the user lists explicitly are derived. The +`enum_from_string` / `enum_from_symbol` / `string_from_enum` / +`symbol_from_enum` methods are always defined (they have no opt-out in +`@enumbatteries` either). + +# Example +```julia +@enum Color Red Blue Yellow +@enumbattery Color symbol_conversion # only the symbol-conversion batteries +@enumbattery Color hash typesalt=0xab # only stable hash +``` +""" +macro enumbattery(T, kw...) + def_enumbatteries(__module__, T, kw, ENUM_BATTERIES_NONE) |> esc +end + +# Shared codegen for `@enumbatteries` / `@enumbattery`. The two macros +# differ only in `base`: `ENUM_BATTERIES_DEFAULTS` (most batteries on) vs +# `ENUM_BATTERIES_NONE` (everything off, except the always-on +# `enum_from_*` / `*_from_enum` methods). +function def_enumbatteries(__module__, T, kw, base) nt = parse_all_macro_kw(kw) for (pname, val) in pairs(nt) if !(pname in propertynames(ENUM_BATTERIES_DEFAULTS)) @@ -557,7 +647,10 @@ macro enumbatteries(T, kw...) """) end end - nt = merge(ENUM_BATTERIES_DEFAULTS, (; hash = haskey(nt, :typesalt)), nt) + # `typesalt` only makes sense with `hash=true`. If the user passed + # `typesalt` without `hash`, infer `hash=true` so the salt isn't + # silently ignored. Explicit `hash=false typesalt=...` still wins. + nt = merge(base, (; hash = haskey(nt, :typesalt) || base.hash), nt) TT = Base.eval(__module__, T)::Type ret = quote end @@ -592,7 +685,7 @@ macro enumbatteries(T, kw...) push!(ret.args, def) end push!(ret.args, def_has_batteries(T)) - return esc(ret) + return ret end end #module diff --git a/test/runtests.jl b/test/runtests.jl index 1711df7..1ac5d22 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,4 +1,4 @@ -using StructHelpers: @batteries, StructHelpers, @enumbatteries +using StructHelpers: @batteries, @battery, StructHelpers, @enumbatteries, @enumbattery const SH = StructHelpers using Test @@ -246,6 +246,58 @@ end @test COUNTED_DEFAULTS_CALLS[] == 1 end +# `@battery T ...`: opt-in variant where every default is `false`. Only +# the listed batteries are derived; nothing else. +struct BatOnlyKwconstructor; a; b; end +@battery BatOnlyKwconstructor kwconstructor + +struct BatOnlyEqIsequal; a; end +@battery BatOnlyEqIsequal eq isequal + +struct BatOnlyHash; a; end +@battery BatOnlyHash hash typesalt=0xabcdef0123456789 + +struct BatNothing; a; end +@battery BatNothing # legal: derives only `has_batteries` + +@testset "@battery" begin + # `kwconstructor` enabled, but no `==`, `isequal`, `hash`, + # `getproperties`, `constructorof`, `selfconstructor`. + @test BatOnlyKwconstructor(a=1, b=2).a == 1 + # No structural ==: two structurally equal objects compare false (===), + # but the default Julia `==` on structs (egal-by-fields for immutables) + # may still return true. We assert the *macro* didn't define one by + # checking that `Base.:(==)(::T,::T)` is the generic fallback method, + # i.e. that no method was added with both args ::BatOnlyKwconstructor. + @test !any(methods(==, (BatOnlyKwconstructor, BatOnlyKwconstructor))) do m + m.sig === Tuple{typeof(==), BatOnlyKwconstructor, BatOnlyKwconstructor} + end + # No selfconstructor: outer-of-outer wraps rather than passes through. + @test BatOnlyKwconstructor(BatOnlyKwconstructor(1, 2), 3).a isa BatOnlyKwconstructor + + # `eq` and `isequal` enabled; `hash` is NOT — verify by checking that + # no specialized `Base.hash(::T, ::UInt)` method exists. + @test BatOnlyEqIsequal(1) == BatOnlyEqIsequal(1) + @test isequal(BatOnlyEqIsequal(1), BatOnlyEqIsequal(1)) + @test !any(methods(hash, (BatOnlyEqIsequal, UInt))) do m + m.sig === Tuple{typeof(hash), BatOnlyEqIsequal, UInt} + end + + # `hash` + `typesalt` works in subset mode without auto-enabling + # anything else. + h = 0x123456789abcdef0 + @test hash(BatOnlyHash(7), h) == hash((7,), hash(0xabcdef0123456789, h)) + + # `@battery T` with no flags is legal (only `has_batteries` is set). + @test StructHelpers.has_batteries(BatNothing) + + # `@battery` rejects unknown keywords just like `@batteries`. + if VERSION >= v"1.8" + @test_throws "Unsupported keyword" @macroexpand @battery BatNothing nonsense + @test_throws "Bad keyword argument value" @macroexpand @battery BatNothing kwshow="true" + end +end + @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles @enum Color Red Blue Green @@ -316,6 +368,33 @@ end @test hash(MinusTwo, h) == hash(-2, hash(typesalt, h)) end +# `@enumbattery T ...`: opt-in variant of `@enumbatteries`. +@enum ECol1 ECol1A ECol1B +@enumbattery ECol1 symbol_conversion + +@enum ECol2 ECol2A=4 ECol2B=5 +@enumbattery ECol2 hash typesalt=0xfedcba9876543210 + +@enum ECol3 ECol3A ECol3B +@enumbattery ECol3 # legal: only the always-on enum_from_*/from_enum methods + has_batteries + +@testset "@enumbattery" begin + # symbol_conversion enabled, string_conversion not. + @test ECol1(:ECol1A) === ECol1A + @test Symbol(ECol1A) === :ECol1A + @test_throws Exception ECol1("ECol1A") # string_conversion off + # selfconstructor off too: ECol1(::ECol1) is not defined. + @test_throws Exception ECol1(ECol1A) + + # hash + typesalt works without auto-enabling anything else. + h = 0x55aa55aa55aa55aa + @test hash(ECol2A, h) == hash(4, hash(0xfedcba9876543210, h)) + + # Empty `@enumbattery` is legal; the always-on methods are there. + @test StructHelpers.has_batteries(ECol3) + @test StructHelpers.string_from_enum(ECol3A) == "ECol3A" +end + struct Bad end @testset "Error messages" begin @macroexpand @batteries Bad From 1713708f83b1a1f92e1573671614ebc06772b91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Fri, 8 May 2026 09:13:32 +0200 Subject: [PATCH 3/6] Merge Jan's `config` proposal with the single-argument implementation This implements the first proposal in https://github.com/jw3126/StructHelpers.jl/pull/19#issuecomment-4394392163 --- README.md | 54 ++++++++++++++++++++- src/StructHelpers.jl | 113 +++++++++++++++++++++++++++++++++---------- test/runtests.jl | 103 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 239 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c29c481..7c9b841 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Build Status](https://github.com/jw3126/StructHelpers.jl/workflows/CI/badge.svg)](https://github.com/jw3126/StructHelpers.jl/actions) [![Coverage](https://codecov.io/gh/jw3126/StructHelpers.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/jw3126/StructHelpers.jl) -Sometimes defining a new struct is accompanied by a bit of boilerplate. For instance the default definition of `Base.hash` is by object id. Often a `hash` that is based on the object struture is preferable however. Similar for `==`. +Sometimes defining a new struct is accompanied by a bit of boilerplate. For instance the default definition of `Base.hash` is by object id. Often a `hash` that is based on the object structure is preferable however. Similar for `==`. StructHelpers aims to simplify the boilerplate required for such common tweaks. # Usage @@ -19,7 +19,59 @@ end @batteries S @batteries S hash=false # don't overload `Base.hash` @batteries S kwconstructor=true # add a keyword constructor +@batteries S kwconstructor # bare-symbol shorthand for `kwconstructor=true` ``` + +If you want only a hand-picked subset of batteries (no defaults), use +`@battery`: + +```julia +@battery S kwconstructor # only the keyword constructor +@battery S eq isequal hash # only structural ==, isequal, hash +``` + +To share a configuration across many structs, pass a `NamedTuple`: + +```julia +const config = (kwshow=true, kwconstructor=true, eq=false) + +@batteries S1 config +@batteries S2 config typesalt=0xdeadbeef # config + per-struct override +@battery S3 config # opt-in form, same config +``` + +A config only *overrides* the keys it mentions. With `@batteries`, every +key the config doesn't set keeps its default value (e.g. `S1` above +still gets the default structural `isequal`, `hash`, `selfconstructor` +etc.). With `@battery` the same config picks exactly the listed +batteries and nothing else, since `@battery`'s baseline has every flag +turned off. + +Useful configs could look like this: + +```julia +# Value-like records: nice display + keyword construction, structural +# `==` / `isequal` / `hash` from the defaults. +const value_like = (kwshow=true, kwconstructor=true) + +# Plain data you also want to (de)serialize via StructTypes.jl / JSON3. +const serializable = (kwshow=true, kwconstructor=true, StructTypes=true) + +# Identity semantics: keep ergonomic show/construction, but opt out of +# structural equality and hashing (e.g. for mutable handles or types +# whose fields aren't meaningfully comparable). +const identity_like = (kwshow=true, kwconstructor=true, + eq=false, isequal=false, hash=false) + +# Reproducible cross-machine hashes: combine with a per-struct typesalt. +const stable_hash = (kwshow=true, kwconstructor=true) + +@batteries Point value_like +@batteries Config serializable +@batteries FileHandle identity_like +@batteries CacheKey stable_hash typesalt=0xdeadbeefcafebabe +``` + For all supported options and defaults, consult the docstring: ```julia julia>?@batteries diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 241623c..77fbacf 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -235,11 +235,30 @@ struct S end @batteries S -@batteries S hash=false # don't overload `Base.hash` +@batteries S hash=false # don't overload `Base.hash` @batteries S kwconstructor=true # add a keyword constructor @batteries S kwconstructor # bare symbol is shorthand for `kwconstructor=true` ``` +# Reusing options across many structs + +Each `option` argument can also be an expression that evaluates to a +`NamedTuple`; its fields are spliced into the keyword list. This lets +you share a configuration across many types: + +```julia +const config = (kwshow=true, kwconstructor=true, eq=false) + +@batteries S1 config +@batteries S2 config typesalt=0xdeadbeef # override `typesalt` only +@batteries S3 (kwshow=true, eq=false) # inline NamedTuple literal +``` + +Later arguments override earlier ones (last-write-wins), so explicit +overrides after a config splat behave as expected. The bare-flag +shorthand is reserved for call sites and not stored in `NamedTuple`s +— spell config entries as `flag = true`. + Supported options and defaults are: $(doc_batteries_options()) @@ -272,6 +291,15 @@ end @battery S hash typesalt=0xab # only define hash, with stable typesalt ``` +`@battery` accepts the same `NamedTuple` config splatting as +[`@batteries`](@ref): + +```julia +const minimal = (kwshow=true, kwconstructor=true) +@battery T minimal # only the two batteries in `minimal` +@battery T minimal eq=true # the two from `minimal` plus structural == +``` + Supported options are the same as for [`@batteries`](@ref); only the defaults differ (every Bool defaults to `false`, `typesalt` to `nothing`). """ @@ -284,7 +312,7 @@ end # (everything off). The same validation and emission logic is used in both # cases. function def_batteries(__module__, T, kw, base) - nt = parse_all_macro_kw(kw) + nt = parse_all_macro_kw(kw, __module__, propertynames(BATTERIES_DEFAULTS)) for (pname, val) in pairs(nt) if !(pname in propertynames(BATTERIES_DEFAULTS)) error(""" @@ -404,32 +432,56 @@ function error_parse_macro_kw(kw; comment=nothing) end error(msg) end -function parse_single_macro_kw(kw) - # Bare-symbol shorthand: `flag` is sugar for `flag=true`. Lets users write - # `@batteries T kwconstructor` instead of `@batteries T kwconstructor=true`. - # The resulting `flag => true` is then validated by the macro itself, so - # bare symbols that don't name a Bool option (e.g. `typesalt`) still - # produce the standard "Bad keyword argument value" error. - kw isa Symbol && return (kw => true) - Meta.isexpr(kw, Symbol("=")) || error_parse_macro_kw(kw) - length(kw.args) == 2 || error_parse_macro_kw(kw) - key, val = kw.args - key isa Symbol || error_parse_macro_kw(kw, comment="key = $key must be a symbol") - (key => val) -end -function parse_all_macro_kw(kw) - pairs = map(parse_single_macro_kw, kw) - if !(allunique(map(first, pairs))) - error( - """ - Keywords must be unique. Got: - $(kw) - """ - ) + +# Evaluate `expr` in `__module__` and return its `pairs(...)` as a +# `Vector{Pair{Symbol,Any}}`. Used to splat a config-style binding or +# an inline `(a=1, b=2)` literal into the macro's keyword stream. Errors +# with a clear message if evaluation fails or the result isn't a +# `NamedTuple`. +function eval_namedtuple_arg(expr, __module__) + bad(reason) = error(""" + Bad argument: $(repr(expr)) + Expected a flag, `name = value`, or an expression evaluating to a `NamedTuple`. + $reason + """) + val = try + Base.eval(__module__, expr) + catch e + bad("Evaluation failed:\n$(sprint(showerror, e))") + end + val isa NamedTuple || bad("Got: $(repr(val))::$(typeof(val))") + return Pair{Symbol,Any}[k => v for (k, v) in pairs(val)] +end + +function parse_single_macro_kw(kw, __module__, allowed_keys) + # Bare-symbol shorthand: `flag` is sugar for `flag=true`. Lets users + # write `@batteries T kwconstructor` instead of + # `@batteries T kwconstructor=true`. Only symbols naming an actual + # flag are treated as such; other bare symbols are evaluated as + # NamedTuple-valued bindings (config-style) and splatted. + if kw isa Symbol + return kw in allowed_keys ? Pair{Symbol,Any}[kw => true] : + eval_namedtuple_arg(kw, __module__) end - (;pairs...) + if Meta.isexpr(kw, Symbol("=")) + length(kw.args) == 2 || error_parse_macro_kw(kw) + key, val = kw.args + key isa Symbol || error_parse_macro_kw(kw, comment="key = $key must be a symbol") + return Pair{Symbol,Any}[key => val] + end + # Anything else: try to evaluate as a NamedTuple-valued expression. + # This supports inline literals like `(kwshow=true, eq=false)` and + # arbitrary expressions that resolve to a NamedTuple. + return eval_namedtuple_arg(kw, __module__) end +# Fold all macro arguments into a single NamedTuple. Later arguments +# override earlier ones (last-write-wins, courtesy of NamedTuple +# construction), so the common pattern `@batteries S config typesalt=0xab` +# cleanly extends a config with explicit overrides. +parse_all_macro_kw(kw, __module__, allowed_keys) = + (; (p for k in kw for p in parse_single_macro_kw(k, __module__, allowed_keys))...) + ################################################################################ #### enum ################################################################################ @@ -587,6 +639,15 @@ Automatically derive several methods for Enum type `T`. @enumbatteries Color symbol_conversion # bare symbol is shorthand for `symbol_conversion=true` ``` +Like [`@batteries`](@ref), arguments may also be `NamedTuple`-valued +expressions whose fields are spliced in (config-style): + +```julia +const enum_config = (string_conversion=true, symbol_conversion=true) +@enumbatteries Color enum_config +@enumbatteries Color enum_config hash=false # override +``` + Supported options and defaults are: $(doc_enum_batteries_options()) @@ -624,7 +685,7 @@ end # `ENUM_BATTERIES_NONE` (everything off, except the always-on # `enum_from_*` / `*_from_enum` methods). function def_enumbatteries(__module__, T, kw, base) - nt = parse_all_macro_kw(kw) + nt = parse_all_macro_kw(kw, __module__, propertynames(ENUM_BATTERIES_DEFAULTS)) 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 1ac5d22..4a70c07 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -145,9 +145,11 @@ struct SBare; a; b; end if VERSION >= v"1.8" @test_throws "Bad keyword argument value:" @macroexpand @batteries SErrors kwconstructor="true" @test_throws "Unsupported keyword" @macroexpand @batteries SErrors kwconstructor=true nonsense=true - # Bare symbol is now sugar for `=true`, so an unknown bare flag fails - # as an unsupported keyword rather than as a parse error. - @test_throws "Unsupported keyword" @macroexpand @batteries SErrors nonsense + # Bare symbol naming a known flag is sugar for `=true`. A bare + # symbol that is *not* a known flag falls through to the + # NamedTuple-config path; if it isn't bound to anything either, + # we report "Bad argument" with the failed evaluation. + @test_throws "Bad argument" @macroexpand @batteries SErrors nonsense else @test_throws Exception @macroexpand @batteries SErrors kwconstructor="true" @test_throws Exception @macroexpand @batteries SErrors kwconstructor=true nonsense=true @@ -293,11 +295,104 @@ struct BatNothing; a; end # `@battery` rejects unknown keywords just like `@batteries`. if VERSION >= v"1.8" - @test_throws "Unsupported keyword" @macroexpand @battery BatNothing nonsense + # 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 "Bad keyword argument value" @macroexpand @battery BatNothing kwshow="true" end end +# `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. +const config_kwshow_kwconst = (kwshow=true, kwconstructor=true) +const config_with_typesalt = (kwshow=true, kwconstructor=true, typesalt=0x42) +const config_no_kwconstructor = (kwconstructor=false,) + +# 1. Single config splat into `@batteries`. The defaults still apply for +# keys not in the config; the config wins for keys it sets. +struct CfgA; a; b; end +@batteries CfgA config_kwshow_kwconst + +# 2. Config + per-struct override: explicit `typesalt=...` wins over +# whatever the config says (and over the default). +struct CfgB; a; b; end +@batteries CfgB config_with_typesalt typesalt=0x99 + +# 3. Two configs combined; later overrides earlier. +struct CfgC; a; b; end +@batteries CfgC config_kwshow_kwconst config_no_kwconstructor + +# 4. Inline NamedTuple literal (no const required). +struct CfgD; a; b; end +@batteries CfgD (kwshow=true, kwconstructor=true) + +# 5. `@battery` (opt-in form) plus a config: only the config's batteries +# are derived, nothing else. +struct CfgE; a; b; end +@battery CfgE config_kwshow_kwconst + +# 6. `@battery` plus a config plus a bare-flag override. +struct CfgF; a; end +@battery CfgF config_kwshow_kwconst eq + +@testset "NamedTuple config splat" begin + # The behavior of every individual flag (kwshow, kwconstructor, ==, + # isequal, hash, typesalt, ...) is already exercised exhaustively + # by the @batteries / @battery testsets above. The job of *this* + # testset is to verify that the splatting machinery sets exactly + # the flags the user asked for and nothing else, regardless of + # whether they came from a const config, an inline NamedTuple + # literal, multiple configs, or a config + override. + has_method(f, sig) = any(m -> m.sig === Tuple{typeof(f), sig...}, methods(f, sig)) + + # 1. `@batteries` + config: config-set flags ON, untouched flags + # keep their (mostly-on) defaults. + @test has_method(==, (CfgA, CfgA)) # default + @test has_method(isequal, (CfgA, CfgA)) # default + @test has_method(hash, (CfgA, UInt)) # default + + # 2. Per-struct override wins over the config it follows. typesalt + # is the only flag whose value can be observed without method + # introspection, hence the explicit hash check. + h = UInt(0) + @test hash(CfgB(7, 8), h) === hash((7, 8), hash(0x99, h)) + @test hash(CfgB(7, 8), h) !== hash((7, 8), hash(0x42, h)) + + # 3. Two-config last-write-wins: the second config's + # `kwconstructor=false` undoes the first config's `=true`. + @test_throws MethodError CfgC(a=1, b=2) + + # 4. Inline NamedTuple literal: same effect as a const config. + @test has_method(==, (CfgD, CfgD)) + @test CfgD(a=1, b=2) === CfgD(1, 2) # kwconstructor reached us + + # 5. `@battery` + config: only the config's flags are set; defaults + # are NOT pulled in (this is what distinguishes the two macros). + @test !has_method(==, (CfgE, CfgE)) + @test !has_method(isequal, (CfgE, CfgE)) + @test !has_method(hash, (CfgE, UInt)) + + # 6. `@battery` + config + bare-flag override: bare `eq` adds `==` + # on top of the config; isequal/hash still absent. + @test has_method(==, (CfgF, CfgF)) + @test !has_method(isequal, (CfgF, CfgF)) + @test !has_method(hash, (CfgF, UInt)) + + @testset "errors" begin + if VERSION >= v"1.8" + # Bare symbol that's neither a flag nor a binding. + @test_throws "Bad argument" @macroexpand @batteries CfgA undefined_config + # Bound but not a NamedTuple. + global not_a_namedtuple = 42 + @test_throws "Bad argument" @macroexpand(@batteries CfgA not_a_namedtuple) + # Inline non-NamedTuple expression. + @test_throws "Bad argument" @macroexpand @batteries CfgA 1 + 2 + end + end +end + @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles @enum Color Red Blue Green From 671b33ae1a95eb81d26e50ffb26f1e58d316747b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Fri, 8 May 2026 13:44:12 +0200 Subject: [PATCH 4/6] Invert `NamedTuple` priority for forward-compatibility Co-authored-by: Copilot --- src/StructHelpers.jl | 40 ++++++++++++++++++++++++++---- test/runtests.jl | 58 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/StructHelpers.jl b/src/StructHelpers.jl index 77fbacf..3031bd8 100644 --- a/src/StructHelpers.jl +++ b/src/StructHelpers.jl @@ -259,6 +259,17 @@ overrides after a config splat behave as expected. The bare-flag shorthand is reserved for call sites and not stored in `NamedTuple`s — spell config entries as `flag = true`. +## Bare-symbol resolution + +A bare symbol argument is resolved by checking the calling module +*first*: if it names a `NamedTuple` binding, that binding is splatted; +otherwise, if it names a flag, it is treated as `flag = true`. User +bindings winning over flag names is deliberate, so adding new flags +in future versions of `StructHelpers` cannot silently shadow a user's +config binding that happens to share the new flag's name. To adopt a +newly added flag with a colliding binding name, either rename the +binding or spell the flag explicitly as `flag = true`. + Supported options and defaults are: $(doc_batteries_options()) @@ -456,12 +467,31 @@ end function parse_single_macro_kw(kw, __module__, allowed_keys) # Bare-symbol shorthand: `flag` is sugar for `flag=true`. Lets users # write `@batteries T kwconstructor` instead of - # `@batteries T kwconstructor=true`. Only symbols naming an actual - # flag are treated as such; other bare symbols are evaluated as - # NamedTuple-valued bindings (config-style) and splatted. + # `@batteries T kwconstructor=true`. We resolve a bare symbol by + # checking the caller's module *first*: if it names a `NamedTuple` + # binding, we splat it as config; otherwise, if it names a flag, we + # treat it as `flag=true`. User bindings winning over flag names is + # deliberate — it means StructHelpers can add new flags in future + # versions without silently shadowing a user's config binding that + # happens to share the new flag's name. The user only needs to + # rename their binding (or spell the new flag as `flag=true`) to + # adopt the new option. if kw isa Symbol - return kw in allowed_keys ? Pair{Symbol,Any}[kw => true] : - eval_namedtuple_arg(kw, __module__) + if isdefined(__module__, kw) + val = Base.eval(__module__, kw) + if val isa NamedTuple + return Pair{Symbol,Any}[k => v for (k, v) in pairs(val)] + end + end + if kw in allowed_keys + return Pair{Symbol,Any}[kw => true] + end + error(""" + Bad argument: $(repr(kw)) + A bare symbol must either name a flag or a `NamedTuple` binding + in the calling module. Got neither. + Allowed flags: $(collect(allowed_keys)) + """) end if Meta.isexpr(kw, Symbol("=")) length(kw.args) == 2 || error_parse_macro_kw(kw) diff --git a/test/runtests.jl b/test/runtests.jl index 4a70c07..5b4a065 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -337,6 +337,25 @@ struct CfgE; a; b; end struct CfgF; a; end @battery CfgF config_kwshow_kwconst eq +# 7. Multiple disjoint configs combined with a per-struct override. +const cfg_part_kwshow = (kwshow=true,) +const cfg_part_kwconstructor = (kwconstructor=true,) +const cfg_part_typesalt = (typesalt=0x42,) +struct CfgG; a; b; end +@batteries CfgG cfg_part_kwshow cfg_part_kwconstructor cfg_part_typesalt typesalt=0x99 + +# Forward-compatibility regression test: a bare symbol that names *both* +# a flag *and* a NamedTuple binding in the calling module must resolve +# to the binding (so future flag additions cannot silently shadow user +# configs sharing the new flag's name). `kwconstructor` is an existing +# flag; the binding below maps it to a NamedTuple that turns on +# `kwshow` instead — proving the binding won and the flag did not. +const kwconstructor = (kwshow = true,) +struct CfgShadow; a; end +@battery CfgShadow kwconstructor +struct CfgExplicit; a; end +@battery CfgExplicit kwconstructor = true + @testset "NamedTuple config splat" begin # The behavior of every individual flag (kwshow, kwconstructor, ==, # isequal, hash, typesalt, ...) is already exercised exhaustively @@ -380,17 +399,54 @@ struct CfgF; a; end @test !has_method(isequal, (CfgF, CfgF)) @test !has_method(hash, (CfgF, UInt)) + # 7. Multiple disjoint configs + per-struct override. All three + # config NamedTuples are spliced in; the explicit `typesalt` + # overrides the one set by `cfg_part_typesalt`. Because this is + # `@batteries` (not `@battery`), the defaults still apply for + # keys none of the configs touched. + @test CfgG(a=1, b=2) === CfgG(1, 2) + @test sprint(show, CfgG(1, 2)) == "CfgG(a = 1, b = 2)" + let h = UInt(0) + # typesalt from explicit override, not from cfg_part_typesalt. + @test hash(CfgG(7, 8), h) === hash((7, 8), hash(0x99, h)) + @test hash(CfgG(7, 8), h) !== hash((7, 8), hash(0x42, h)) + end + # Defaults untouched by any of the three configs are still on: + @test has_method(==, (CfgG, CfgG)) + @test has_method(isequal, (CfgG, CfgG)) + @test has_method(hash, (CfgG, UInt)) + # And the methods actually defined by the configs are there: + @test has_method(show, (IO, CfgG)) + @test hasmethod(CfgG, Tuple{}, (:a, :b)) + @testset "errors" begin if VERSION >= v"1.8" # Bare symbol that's neither a flag nor a binding. @test_throws "Bad argument" @macroexpand @batteries CfgA undefined_config - # Bound but not a NamedTuple. + # Bound but not a NamedTuple, and not a flag name either. global not_a_namedtuple = 42 @test_throws "Bad argument" @macroexpand(@batteries CfgA not_a_namedtuple) # Inline non-NamedTuple expression. @test_throws "Bad argument" @macroexpand @batteries CfgA 1 + 2 end end + + @testset "binding wins over flag name" begin + # Forward-compatibility guarantee: when a bare symbol names + # *both* a flag *and* a NamedTuple binding in the calling + # module, the binding wins. This means StructHelpers can add + # new flags in the future without silently shadowing a user's + # const config that happens to share the new flag's name; the + # user only needs to rename the binding (or spell the flag + # explicitly as `flag = true`) to opt into the new flag. + @test :kwconstructor in StructHelpers.BATTERIES_ALLOWED_KW + # The NamedTuple binding was splatted ⇒ kwshow is on. + @test sprint(show, CfgShadow(1)) == "CfgShadow(a = 1,)" + # The flag interpretation was *not* taken ⇒ no kw constructor. + @test_throws MethodError CfgShadow(a = 1) + # The explicit form still reaches the flag. + @test CfgExplicit(a = 1).a == 1 + end end @enum EnumNoBatteries UsesGas UsesPlug UsesMuscles From eefafb3d918e52273a8b60305653329341832b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Thu, 14 May 2026 04:56:06 +0200 Subject: [PATCH 5/6] Remove trailing comma in test case Co-authored-by: Copilot --- test/runtests.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runtests.jl b/test/runtests.jl index 5b4a065..fe41593 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -441,7 +441,7 @@ struct CfgExplicit; a; end # explicitly as `flag = true`) to opt into the new flag. @test :kwconstructor in StructHelpers.BATTERIES_ALLOWED_KW # The NamedTuple binding was splatted ⇒ kwshow is on. - @test sprint(show, CfgShadow(1)) == "CfgShadow(a = 1,)" + @test sprint(show, CfgShadow(1)) == "CfgShadow(a = 1)" # The flag interpretation was *not* taken ⇒ no kw constructor. @test_throws MethodError CfgShadow(a = 1) # The explicit form still reaches the flag. From 48ff0517f61ffe94248e5bd9d2d12b5e52b28de8 Mon Sep 17 00:00:00 2001 From: Jan Weidner Date: Thu, 14 May 2026 08:23:36 +0200 Subject: [PATCH 6/6] Bump version to 1.6.0 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index e811b11..7b95fcf 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "StructHelpers" uuid = "4093c41a-2008-41fd-82b8-e3f9d02b504f" authors = ["Jan Weidner and contributors"] -version = "1.5.1" +version = "1.6.0" [deps] ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9"