field_struct 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. metadata +139 -0
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Base class for every error FieldStruct raises. Catch this to handle
5
+ # any library error generically.
6
+ class Error < StandardError; end
7
+
8
+ # Raised by the setter pipeline when a value cannot be coerced into a
9
+ # field's declared type and the class's +coercion_policy+ is +:raise+.
10
+ #
11
+ # Carries the field name and the original underlying error for callers
12
+ # that want to inspect what happened.
13
+ class CoercionError < Error
14
+ # @return [Symbol, nil] the field whose setter triggered the failure
15
+ attr_reader :field_name
16
+
17
+ # @return [StandardError, nil] the underlying error from the type's coerce
18
+ attr_reader :original
19
+
20
+ # @param message [String]
21
+ # @param field_name [Symbol, nil]
22
+ # @param original [StandardError, nil]
23
+ def initialize(message, field_name: nil, original: nil)
24
+ super(message)
25
+ @field_name = field_name
26
+ @original = original
27
+ end
28
+ end
29
+
30
+ # Raised when a field setter on a class marked +immutable!+ is called
31
+ # after construction.
32
+ class ImmutableError < Error; end
33
+
34
+ # Raised by {Base#initialize} / {Base#assign_attributes} when the input
35
+ # hash includes a key that is not a declared field on the class and
36
+ # the +unknown_attributes+ policy is +:raise+.
37
+ class UnknownAttributeError < Error
38
+ # @return [Array<Symbol>] the offending keys
39
+ attr_reader :keys
40
+
41
+ # @param message [String]
42
+ # @param keys [Array<Symbol>]
43
+ def initialize(message, keys: [])
44
+ super(message)
45
+ @keys = keys
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Per-instance collection of validation messages, keyed by field name.
5
+ #
6
+ # Shaped after ActiveModel::Errors but reuses none of its code. Each
7
+ # FieldStruct instance gets its own Errors object; the setter pipeline
8
+ # clears and rewrites a field's entry on every assignment.
9
+ class Errors
10
+ def initialize
11
+ @messages = {}
12
+ end
13
+
14
+ # Ruby hook behind +dup+ / +clone+: copy the message lists too, so
15
+ # adding to the copy never changes the original.
16
+ #
17
+ # @api private
18
+ # @param source [self] the collection being copied
19
+ # @return [void]
20
+ def initialize_copy(source)
21
+ super
22
+ @messages = source.to_h.transform_values(&:dup)
23
+ end
24
+
25
+ # @param field_name [Symbol, String]
26
+ # @return [Array<String>] messages for the field, or an empty array
27
+ def [](field_name)
28
+ @messages[field_name.to_sym] || []
29
+ end
30
+
31
+ # @param field_name [Symbol, String]
32
+ # @param message [String]
33
+ # @return [Array<String>] the field's updated message list
34
+ def add(field_name, message)
35
+ key = field_name.to_sym
36
+ (@messages[key] ||= []) << message
37
+ end
38
+
39
+ # Drop every message for a field.
40
+ #
41
+ # @param field_name [Symbol, String]
42
+ # @return [void]
43
+ def clear(field_name)
44
+ @messages.delete(field_name.to_sym)
45
+ nil
46
+ end
47
+
48
+ # @return [Boolean] true when no field has any messages
49
+ def empty?
50
+ # A block loop, not each_value.all?: that builds an Enumerator per call,
51
+ # and every validity check asks this.
52
+ @messages.each_value { |messages| return false unless messages.empty? }
53
+ true
54
+ end
55
+
56
+ # @return [Hash{Symbol=>Array<String>}] snapshot of field → messages,
57
+ # skipping fields with empty message lists
58
+ def to_h
59
+ @messages.reject { |_, msgs| msgs.empty? }
60
+ end
61
+ alias messages to_h
62
+
63
+ # Every message rendered as a full, human-readable sentence: the
64
+ # humanized field name prepended to each message. Messages on +:base+
65
+ # (the cross-field convention) pass through unprefixed — they are
66
+ # already complete sentences. Order follows field insertion order, then
67
+ # message order within a field.
68
+ #
69
+ # errors.add(:first_name, 'is required')
70
+ # errors.full_messages # => ["First name is required"]
71
+ #
72
+ # @return [Array<String>]
73
+ def full_messages
74
+ to_h.flat_map do |field, msgs|
75
+ next msgs if field == :base
76
+
77
+ prefix = humanize(field)
78
+ msgs.map { |msg| "#{prefix} #{msg}" }
79
+ end
80
+ end
81
+
82
+ # @return [String]
83
+ def inspect
84
+ prefix = "#{FieldStruct.inspect_namespace}::Errors"
85
+ return "#<#{prefix} empty>" if empty?
86
+
87
+ pairs = to_h.map { |field, msgs| "#{field}=#{msgs.inspect}" }.join(' ')
88
+ "#<#{prefix} #{pairs}>"
89
+ end
90
+
91
+ private
92
+
93
+ # Turn a field name into a display label for {#full_messages}:
94
+ # underscores become spaces and the first character is upcased.
95
+ # Deliberately minimal — no inflection, no +_id+ stripping, no i18n.
96
+ #
97
+ # @param field [Symbol]
98
+ # @return [String]
99
+ def humanize(field)
100
+ field.to_s.tr('_', ' ').sub(/\A./, &:upcase)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # A single declared attribute on a FieldStruct class.
5
+ #
6
+ # Holds the field's name, the resolved type class, whether it's
7
+ # required, an optional default, and any remaining keyword options
8
+ # (e.g. +format:+). Frozen after construction — the DSL is expected
9
+ # to build a fresh Field rather than mutate one.
10
+ class Field
11
+ # @return [Symbol] canonical field name
12
+ attr_reader :name
13
+
14
+ # @return [Class<Types::Base>] the resolved type class
15
+ attr_reader :type
16
+
17
+ # @return [Object, #call, nil] the configured default value, or a
18
+ # parameterless callable returning one, or +nil+ if no default
19
+ attr_reader :default
20
+
21
+ # @return [Hash{Symbol=>Object, nil}] frozen, holds the remaining keyword
22
+ # options forwarded to {Types::Base#coerce} (e.g. +:format+,
23
+ # +:round+, +:values+, +:of_type+, +:enum+, +:in+)
24
+ attr_reader :options
25
+
26
+ # @return [Types::Base] an eagerly-built instance of {#type}, reused by
27
+ # the setter pipeline so we don't allocate a fresh type per coercion
28
+ attr_reader :type_instance
29
+
30
+ # @return [:keep_raw, :replace, :raise, nil] this field's coercion-
31
+ # failure policy override, or +nil+ to defer to the class-level
32
+ # setting on {Base.coercion_policy}.
33
+ attr_reader :coercion_policy
34
+
35
+ # @return [String, nil] human-readable description of the field;
36
+ # intended for downstream documentation generators. Not part of
37
+ # the data surface — does not appear in +attributes+ / +as_json+ /
38
+ # pattern matches.
39
+ attr_reader :description
40
+
41
+ # @return [String, nil] an RBS type expression that {RBS.generate} and
42
+ # {RBS.type_for} emit for this field instead of deriving one from the
43
+ # type (e.g. +"::ActiveRecord::Relation"+ on a +:value+ field), or
44
+ # +nil+ to derive it. Declared with the +rbs_type:+ field option;
45
+ # signatures only — it never affects coercion or validation.
46
+ attr_reader :rbs_type
47
+
48
+ # @api private
49
+ # @return [Symbol] the setter's method name (+:name=+), built once here
50
+ # so the setter pipeline doesn't intern a dynamic Symbol per call
51
+ attr_reader :setter_name
52
+
53
+ # @api private
54
+ # @return [Symbol] the instance variable holding the value (+:@name+),
55
+ # built once for the same reason as {#setter_name}
56
+ attr_reader :ivar_name
57
+
58
+ # @param name [Symbol, String] canonical field name
59
+ # @param type [Class<Types::Base>] the resolved type class
60
+ # @param type_instance [Types::Base, nil] optional pre-built instance —
61
+ # used by the DSL for parameterized types (e.g. {Types::Nested}) where
62
+ # +type.new+ wouldn't be enough to capture the parameterization. When
63
+ # nil, +type.new+ is called.
64
+ # @param required [Boolean]
65
+ # @param default [Object, nil] literal default value, or a
66
+ # parameterless callable (Proc/Lambda/Method) that returns one.
67
+ # Callables are invoked once per instance during apply_defaults.
68
+ # @param coercion_policy [:keep_raw, :replace, :raise, nil] override
69
+ # the class-level coercion policy for this one field; +nil+ means
70
+ # "use whatever the class says"
71
+ # @param description [String, nil] human-readable description of the
72
+ # field; for documentation purposes only
73
+ # @param rbs_type [String, nil] RBS type expression overriding the
74
+ # derived one in generated signatures (see {#rbs_type})
75
+ # @param sensitive [Boolean] mask the value in {Base#inspect} (see {#sensitive?})
76
+ # @param options [Object, nil] extra keyword type/field options
77
+ # forwarded to coerce (e.g. +format:+, +of_type:+, +round:+,
78
+ # +values:+, +enum:+, +in:+)
79
+ def initialize(name:, type:, type_instance: nil, required: false, default: nil, coercion_policy: nil, description: nil, rbs_type: nil, sensitive: false, **options)
80
+ @name = name.to_sym
81
+ @setter_name = :"#{@name}="
82
+ @ivar_name = :"@#{@name}"
83
+ @type = type
84
+ @required = required
85
+ @default = default
86
+ @options = options.freeze
87
+ @type_instance = type_instance || type.new
88
+ @coercion_policy = coercion_policy
89
+ @description = description
90
+ @rbs_type = rbs_type
91
+ @sensitive = sensitive
92
+ freeze
93
+ end
94
+
95
+ # @return [Boolean]
96
+ def required?
97
+ @required
98
+ end
99
+
100
+ # Whether the field holds data that must not reach a log — a card
101
+ # number, a CVV, a token. {Base#inspect} (and so +pp+) renders
102
+ # {FieldStruct::FILTERED} in place of a non-nil value. Data paths —
103
+ # the reader, +attributes+, +to_h+, +as_json+, +to_json+, pattern
104
+ # matching — return the real value, so round-trips stay exact.
105
+ # Declared with the +sensitive:+ field option.
106
+ #
107
+ # @return [Boolean]
108
+ def sensitive?
109
+ @sensitive
110
+ end
111
+
112
+ # @return [String, nil] shorter alias of {#description}
113
+ alias desc description
114
+
115
+ # Concise one-line representation. Designed to be readable in IRB
116
+ # without dumping every ivar (the default Object#inspect is the
117
+ # reason +optional :x, :string+ in a console looks like a wall of
118
+ # text).
119
+ #
120
+ # @return [String]
121
+ def inspect
122
+ parts = [@name.inspect, type_repr]
123
+ parts << 'required' if @required
124
+ parts << 'sensitive' if @sensitive
125
+ parts << "default=#{@default.inspect}" unless @default.nil?
126
+ parts << "coercion_policy=#{@coercion_policy.inspect}" if @coercion_policy
127
+ parts << "description=#{@description.inspect}" if @description
128
+ parts << "rbs_type=#{@rbs_type.inspect}" if @rbs_type
129
+ @options.each { |key, value| parts << "#{key}=#{option_repr(value)}" }
130
+ "#<#{FieldStruct.inspect_namespace}::Field #{parts.join(" ")}>"
131
+ end
132
+
133
+ # Delegates pretty-print to {#inspect} so IRB / pp don't fall back
134
+ # to the default reflection-based output.
135
+ #
136
+ # @param pp [Object] a PP-shaped sink (responds to +#text+)
137
+ # @return [void]
138
+ def pretty_print(pp)
139
+ pp.text(inspect)
140
+ end
141
+
142
+ private
143
+
144
+ # @return [String] the type's short name, or +Nested(...)+ / +Union(...)+
145
+ def type_repr
146
+ base = @type.name.to_s.split('::').last
147
+ if @type <= FieldStruct::Types::Nested
148
+ "Nested(#{@type_instance.struct_class.name || "AnonymousFieldStruct"})"
149
+ elsif @type <= FieldStruct::Types::Union
150
+ members = @type_instance.member_types.map { |m| m.class.name.to_s.split('::').last }
151
+ "Union(#{members.join(" | ")})"
152
+ else
153
+ base
154
+ end
155
+ end
156
+
157
+ # Render an option's value in inspect output. Type-class values
158
+ # (e.g. +:of_type+ on an Array field) get their basename instead
159
+ # of the full +FieldStruct::Types::String+ class.inspect.
160
+ #
161
+ # @param value [Object, nil] an option value
162
+ # @return [String]
163
+ def option_repr(value)
164
+ case value
165
+ when ::Class
166
+ value.name.to_s.split('::').last
167
+ when FieldStruct::Types::Nested
168
+ "Nested(#{value.struct_class.name || "AnonymousFieldStruct"})"
169
+ when FieldStruct::Types::Base
170
+ value.class.name.to_s.split('::').last
171
+ # Spelled out rather than Hash#inspect, whose format changed in Ruby 3.4.
172
+ when ::Hash
173
+ "{#{value.map { |key, item| "#{key}: #{option_repr(item)}" }.join(", ")}}"
174
+ else
175
+ value.inspect
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # The per-class collection of {Field} declarations.
5
+ #
6
+ # Mutable while a class is being defined — the DSL adds fields as it
7
+ # encounters them — and queried thereafter by name or via iteration.
8
+ # Subclass inheritance is handled by {#merge}: a fresh child Metadata
9
+ # is seeded with the parent's fields, then accumulates its own; child
10
+ # declarations win on conflict.
11
+ class Metadata
12
+ include Enumerable
13
+
14
+ def initialize
15
+ @fields = {}
16
+ @serializations = {}
17
+ @reverse_serializations = {}
18
+ end
19
+
20
+ # @return [Hash{Symbol=>Hash{Symbol=>String}}] declared
21
+ # external-name mappings keyed by format name. Each inner hash
22
+ # maps an internal field name (Symbol) to its external name
23
+ # (String). Fields not listed in an inner hash serialize under
24
+ # their canonical name.
25
+ attr_reader :serializations
26
+
27
+ # @param field [Field]
28
+ # @return [self]
29
+ def add(field)
30
+ @fields[field.name] = field
31
+ self
32
+ end
33
+
34
+ # @param name [Symbol, String]
35
+ # @return [Field, nil]
36
+ def [](name)
37
+ @fields[name.to_sym]
38
+ end
39
+
40
+ # @return [Array<Symbol>] field names in insertion order
41
+ def names
42
+ @fields.keys
43
+ end
44
+
45
+ # Yields each {Field} in insertion order.
46
+ #
47
+ # @yield [field]
48
+ # @yieldparam field [Field]
49
+ # @return [Enumerator<Field>] when no block is given
50
+ def each(&block)
51
+ @fields.each_value(&block)
52
+ end
53
+
54
+ # A copy-pasteable schema view of the declared fields, keyed by field
55
+ # name. Each value is a plain Hash of primitives/strings (no live type
56
+ # objects), so the result reads cleanly in a console and is safe to
57
+ # `pp` or dump — handy for seeing a model's shape without reading its
58
+ # source. Option values that are classes or type instances are rendered
59
+ # as short names; a callable default shows as +"<callable>"+.
60
+ #
61
+ # User.metadata.to_h
62
+ # # => { name: { type: "String", ruby_type: "String", required: true,
63
+ # # default: nil, options: {}, description: nil }, ... }
64
+ #
65
+ # When a block is given this falls back to +Enumerable#to_h+ (mapping
66
+ # each {Field} to a pair), preserving existing callers.
67
+ #
68
+ # @return [Hash{Symbol=>Hash{Symbol=>Object, nil}}] field name → schema, or
69
+ # the block-mapped pairs when a block is supplied
70
+ def to_h(&block)
71
+ return super if block
72
+
73
+ @fields.transform_values { |field| field_schema(field) }
74
+ end
75
+
76
+ # Record a serialization mapping for the named format. Repeats on
77
+ # the same format name replace the prior mapping (last-write-wins).
78
+ # The mapping is frozen on its way in.
79
+ #
80
+ # @param name [Symbol, String]
81
+ # @param mapping [Hash{Symbol=>String}]
82
+ # @return [self]
83
+ def add_serialization(name, mapping)
84
+ @serializations[name.to_sym] = mapping.freeze
85
+ self
86
+ end
87
+
88
+ # Look up the mapping for a format. Returns an empty hash when the
89
+ # format hasn't been declared — the implicit identity mapping that
90
+ # serializers fall back to.
91
+ #
92
+ # @param name [Symbol, String]
93
+ # @return [Hash{Symbol=>String}] frozen mapping (empty hash when undeclared)
94
+ def serialization(name)
95
+ @serializations[name.to_sym] || EMPTY_SERIALIZATION
96
+ end
97
+
98
+ # The reverse of {#serialization}: external name → field name, which
99
+ # {Base.from_json} needs on every call. Built once per mapping and reused
100
+ # while {#serialization} still returns that same frozen mapping, so any
101
+ # writer that installs a new one ({#add_serialization}, {#merge}, or a
102
+ # direct write to {#serializations}) is picked up on the next call.
103
+ #
104
+ # @api private
105
+ # @param name [Symbol, String]
106
+ # @return [Hash{String=>Symbol}] frozen; empty when the format is undeclared
107
+ def reverse_serialization(name)
108
+ key = name.to_sym
109
+ mapping = serialization(key)
110
+ cached = @reverse_serializations[key]
111
+ return cached.last if cached && cached.first.equal?(mapping)
112
+
113
+ reverse = mapping.each_with_object({}) { |(internal, external), out| out[external] = internal }.freeze
114
+ # A mapping that isn't frozen could change in place, so it isn't cached.
115
+ @reverse_serializations[key] = [mapping, reverse] if mapping.frozen?
116
+ reverse
117
+ end
118
+
119
+ # The identity mapping {#serialization} returns for an undeclared format.
120
+ # @return [Hash{Symbol=>String}]
121
+ EMPTY_SERIALIZATION = {}.freeze
122
+ private_constant :EMPTY_SERIALIZATION
123
+
124
+ # Copy fields and serializations from +parent+ into self, leaving
125
+ # already-declared entries untouched (child wins). Order: existing
126
+ # self fields stay where they are; parent fields are appended in
127
+ # iteration order if not present.
128
+ #
129
+ # @param parent [Metadata]
130
+ # @return [self]
131
+ def merge(parent)
132
+ parent.each do |field|
133
+ @fields[field.name] = field unless @fields.key?(field.name)
134
+ end
135
+ parent.serializations.each do |name, mapping|
136
+ @serializations[name] = mapping unless @serializations.key?(name)
137
+ end
138
+ self
139
+ end
140
+
141
+ # Concise one-line representation. Lists the declared field names
142
+ # and, when present, the declared serialization formats — without
143
+ # dumping the underlying ivar hashes.
144
+ #
145
+ # @return [String]
146
+ def inspect
147
+ return "#<#{FieldStruct.inspect_namespace}::Metadata empty>" if @fields.empty?
148
+
149
+ parts = ["fields=#{@fields.keys.inspect}"]
150
+ parts << "serializations=#{@serializations.keys.inspect}" unless @serializations.empty?
151
+ "#<#{FieldStruct.inspect_namespace}::Metadata #{parts.join(" ")}>"
152
+ end
153
+
154
+ # Multi-line representation suitable for IRB / pp. Renders each
155
+ # field on its own indented line using {Field#inspect}, with the
156
+ # closing +>+ on its own line.
157
+ #
158
+ # @param pp [Object] a PP-shaped sink (responds to +#text+)
159
+ # @return [void]
160
+ def pretty_print(pp)
161
+ if @fields.empty?
162
+ pp.text(inspect)
163
+ return
164
+ end
165
+
166
+ pp.text("#<#{FieldStruct.inspect_namespace}::Metadata")
167
+ @fields.each_value do |field|
168
+ pp.text("\n ")
169
+ pp.text(field.inspect)
170
+ end
171
+ pp.text("\n serializations=#{@serializations.keys.inspect}") unless @serializations.empty?
172
+ pp.text("\n>")
173
+ end
174
+
175
+ # Human/agent-readable summary of the declared fields: one line each
176
+ # with the field's type, required-ness, and the native options its type
177
+ # accepts (with value shapes, required options starred, and any presets).
178
+ # Answers "what can I put here?" without reading source. For the
179
+ # structured form use {#to_h}; for a class-titled version use
180
+ # {Base.describe}.
181
+ #
182
+ # name (String, required) — accepts format (Regexp | Symbol), enum (Array)
183
+ #
184
+ # @return [String]
185
+ def describe
186
+ return '(no fields)' if @fields.empty?
187
+
188
+ @fields.each_value.map { |field| describe_field(field) }.join("\n")
189
+ end
190
+
191
+ private
192
+
193
+ # @param field [Field]
194
+ # @return [String] one +describe+ line for a field
195
+ def describe_field(field)
196
+ flags = [short_name(field.type), field.required? ? 'required' : 'optional']
197
+ flags << 'sensitive' if field.sensitive?
198
+ label = " #{field.name} (#{flags.join(", ")})"
199
+ parts = [label, declared_options_summary(field), accepts_summary(field.type), element_accepts_summary(field)].compact
200
+
201
+ parts.join(' — ')
202
+ end
203
+
204
+ # What this field actually declares, as written.
205
+ #
206
+ # Without this, +describe+ answered only "what COULD go in this field" —
207
+ # the type's option vocabulary — and a field declaring
208
+ # +format: :iso8601, enum: %w[USD EUR]+ rendered identically to one
209
+ # declaring nothing at all. The option schema belongs to the type; this
210
+ # belongs to the field.
211
+ #
212
+ # @param field [Field]
213
+ # @return [String, nil] +nil+ when the field declares no options
214
+ def declared_options_summary(field)
215
+ # Every temporal field carries `format: nil` from apply_default_format,
216
+ # so nils are dropped rather than rendered as a declaration that isn't one.
217
+ declared = field.options.compact
218
+ return nil if declared.empty?
219
+
220
+ declared.map { |key, value| "#{key}: #{declared_option_repr(value)}" }.join(', ')
221
+ end
222
+
223
+ # @return [String] a declared option value, rendered for a human. Type
224
+ # values (an Array field's +of_type:+) show their bare type name rather
225
+ # than a quoted string, so the line reads like the declaration it
226
+ # describes.
227
+ # @param value [Object, nil] a declared option value
228
+ def declared_option_repr(value)
229
+ case value
230
+ when FieldStruct::Types::Nested then "Nested(#{short_name(value.struct_class)})"
231
+ when FieldStruct::Types::Base then short_name(value.class)
232
+ when ::Class then short_name(value)
233
+ # Spelled out rather than Hash#inspect, whose format changed in Ruby 3.4.
234
+ when ::Hash then "{#{value.map { |key, item| "#{key}: #{declared_option_repr(item)}" }.join(", ")}}"
235
+ else value.inspect
236
+ end
237
+ end
238
+
239
+ # What an array field's +element:+ may hold — the names of its element
240
+ # type's native options — so the line answers "what can I pass to each
241
+ # element" too.
242
+ #
243
+ # @param field [Field]
244
+ # @return [String, nil] +nil+ for a non-array field, or an element type
245
+ # with no options (a nested struct)
246
+ def element_accepts_summary(field)
247
+ return nil unless field.type <= FieldStruct::Types::Array
248
+
249
+ # Names only: the shapes are the element type's own (its
250
+ # +option_schema+), and repeating them doubled every array line.
251
+ element_class = FieldStruct::Types::Array.element_class(field.options[:of_type])
252
+ return nil unless element_class.respond_to?(:option_schema)
253
+
254
+ names = element_class.option_schema.keys
255
+ names.empty? ? nil : "element accepts #{names.join(", ")}"
256
+ end
257
+
258
+ # @return [String, nil] the type's native option vocabulary, or +nil+ when
259
+ # the type declares none
260
+ # @param type_class [Class<Types::Base>]
261
+ def accepts_summary(type_class)
262
+ accepts = native_options_summary(type_class)
263
+ accepts.empty? ? nil : "accepts #{accepts}"
264
+ end
265
+
266
+ # @return [String] the type's native options as +name(shapes)+, required
267
+ # ones starred, presets appended — empty when the type has none
268
+ # @param type_class [Class<Types::Base>]
269
+ def native_options_summary(type_class)
270
+ return '' unless type_class.respond_to?(:option_schema)
271
+
272
+ type_class.option_schema.map do |name, descriptor|
273
+ shapes = descriptor[:type].map { |klass| short_name(klass) }.join(' | ')
274
+ star = descriptor[:required] ? '*' : ''
275
+ presets = descriptor[:presets].empty? ? '' : "; presets: #{descriptor[:presets].join(", ")}"
276
+ "#{name}#{star} (#{shapes}#{presets})"
277
+ end.join(', ')
278
+ end
279
+
280
+ # Render one field as a primitive-only schema Hash for {#to_h}.
281
+ #
282
+ # @param field [Field]
283
+ # @return [Hash{Symbol=>Object, nil}]
284
+ def field_schema(field)
285
+ schema = {
286
+ type: short_name(field.type),
287
+ ruby_type: ruby_type_repr(field.type_instance.ruby_type),
288
+ required: field.required?,
289
+ default: schema_value(field.default),
290
+ options: field.options.transform_values { |value| schema_value(value) },
291
+ description: field.description
292
+ }
293
+ # Only when set, so the schema of a field without one is unchanged.
294
+ schema[:rbs_type] = field.rbs_type if field.rbs_type
295
+ schema[:sensitive] = true if field.sensitive?
296
+ schema
297
+ end
298
+
299
+ # A single class, or +A | B+ for a multi-class ruby_type (boolean, union).
300
+ #
301
+ # @param ruby_type [Class, Array<Class>] a type's {Types::Base#ruby_type}
302
+ # @return [String]
303
+ def ruby_type_repr(ruby_type)
304
+ Array(ruby_type).map(&:name).join(' | ')
305
+ end
306
+
307
+ # Reduce an option/default value to something printable: short class
308
+ # names for type references, +"<callable>"+ for procs, recursion into
309
+ # Array/Hash, everything else as-is.
310
+ #
311
+ # @param value [Object, nil] an option or default value
312
+ # @return [Object, nil]
313
+ def schema_value(value)
314
+ case value
315
+ when nil, true, false, ::Numeric, ::String, ::Symbol, ::Regexp, ::Range then value
316
+ when ::Class then short_name(value)
317
+ when FieldStruct::Types::Base then short_name(value.class)
318
+ when ::Array then value.map { |element| schema_value(element) }
319
+ when ::Hash then value.transform_values { |element| schema_value(element) }
320
+ else
321
+ value.respond_to?(:call) ? '<callable>' : value
322
+ end
323
+ end
324
+
325
+ # @param klass [Module]
326
+ # @return [String] the last segment of the class name
327
+ def short_name(klass)
328
+ klass.name.to_s.split('::').last
329
+ end
330
+ end
331
+ end