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,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Scaffolds FieldStruct class source from one or more JSON example objects.
5
+ #
6
+ # The goal is a *working prototype* you then refine by hand — the generator
7
+ # is deliberately conservative about types and never guesses past the
8
+ # evidence. Pass a single object to seed the shape, or an array of
9
+ # same-shape objects to gather more signal (empty fields get resolved from
10
+ # other samples, presence drives required/optional, and small repeated
11
+ # vocabularies surface as enum candidates). A single object is just the
12
+ # one-sample case of the same engine.
13
+ #
14
+ # FieldStruct::Scaffold.from_json(<<~JSON, class_name: 'User')
15
+ # {"name": "Al", "age": 30, "active": true}
16
+ # JSON
17
+ # # class User < FieldStruct::Base
18
+ # # required :name, :string
19
+ # # required :age, :integer
20
+ # # required :active, :boolean
21
+ # # end
22
+ #
23
+ # Inference rules (conservative on purpose):
24
+ #
25
+ # * JSON object → a nested FieldStruct class (emitted before its parent).
26
+ # * JSON array → +:array, of: <element type>+ (element class for arrays of objects).
27
+ # * JSON +true+/+false+ → +:boolean+; integer → +:integer+; real → +:float+.
28
+ # * JSON string → +:string+ — *including numeric-looking strings* (an
29
+ # +authorization_code+ must not become an Integer). Hints like "maybe
30
+ # :big_decimal" are emitted as trailing comments, not committed types.
31
+ # * required when present and non-blank across *every* sample; else optional.
32
+ #
33
+ # The developer adds enums, descriptions, formats, and final scalar types;
34
+ # the comments point at where.
35
+ module Scaffold
36
+ # Matches an integer- or decimal-looking string (kept as +:string+, only hinted).
37
+ # @return [Regexp]
38
+ NUMERIC_STRING = /\A-?\d+(?:\.\d+)?\z/
39
+ # Most distinct repeated string values before a field is flagged as an enum candidate.
40
+ # @return [Integer]
41
+ ENUM_MAX_DISTINCT = 6
42
+ # Names a scaffolded attribute can't take (suffixed with +_field+): Ruby
43
+ # keywords, plus {Base::RESERVED_FIELD_NAMES}, which +field+ refuses.
44
+ #
45
+ # @return [Array<String>]
46
+ RESERVED = (%w[
47
+ BEGIN END alias and begin break case class def defined? do else elsif end
48
+ ensure false for if in module next nil not or redo rescue retry return self
49
+ super then true undef unless until when while yield __FILE__ __LINE__
50
+ ] + Base::RESERVED_FIELD_NAMES.map(&:to_s)).uniq.freeze
51
+
52
+ # Generate FieldStruct class source from JSON example(s).
53
+ #
54
+ # @param input [String, Hash{Object=>Object, nil}, Array<Hash{Object=>Object, nil}>] a JSON string (object or array
55
+ # root), a parsed Hash, or an Array of same-shape Hashes
56
+ # @param class_name [String] the root class name
57
+ # @return [String] Ruby source defining the class and any nested classes
58
+ # @raise [ArgumentError] for an empty set or non-object samples
59
+ def self.from_json(input, class_name: 'Generated')
60
+ samples = normalize(input)
61
+ raise ArgumentError, 'no samples to scaffold from' if samples.empty?
62
+ raise ArgumentError, 'every sample must be a JSON object' unless samples.all?(::Hash)
63
+
64
+ classes = []
65
+ emit_object(class_name, samples, classes, [])
66
+ "#{classes.join("\n\n")}\n"
67
+ end
68
+
69
+ # @param input [String, Hash{Object=>Object, nil}, Array<Hash{Object=>Object, nil}>]
70
+ # @return [Array<Object>] the sample set (a lone object becomes a set of
71
+ # one); {.from_json} checks every element is a Hash
72
+ def self.normalize(input)
73
+ data = input.is_a?(::String) ? Oj.load(input, mode: :strict) : input
74
+ case data
75
+ when ::Array then data
76
+ when ::Hash then [data]
77
+ else
78
+ raise ArgumentError, "expected a JSON object or array of objects, got #{data.class}"
79
+ end
80
+ end
81
+ private_class_method :normalize
82
+
83
+ # Build the class for +samples+ as +class_name+, appending nested classes
84
+ # to +classes+ first (post-order, so references resolve), and return the
85
+ # (deduplicated) class name used.
86
+ #
87
+ # @param class_name [String] the name wanted
88
+ # @param samples [Array<Hash{Object=>Object, nil}>]
89
+ # @param classes [Array<String>] class sources emitted so far (appended to)
90
+ # @param used [Array<String>] class names taken so far (appended to)
91
+ # @return [String]
92
+ def self.emit_object(class_name, samples, classes, used)
93
+ name = unique_name(class_name, used)
94
+ total = samples.length
95
+ mappings = {}
96
+ attrs = []
97
+ lines = ordered_keys(samples).map do |key|
98
+ # Two keys can normalize to one name ("fooBar"/"foo_bar"); suffix the
99
+ # later one so neither value is lost.
100
+ attr = unique_name(safe_attr(key), attrs)
101
+ mappings[attr] = key unless attr == key
102
+ field_line(attr, observe(samples, key), total, classes, used)
103
+ end
104
+ classes << build_class(name, mappings, lines)
105
+ name
106
+ end
107
+ private_class_method :emit_object
108
+
109
+ # @param samples [Array<Hash{Object=>Object, nil}>]
110
+ # @param key [Object] a sample key
111
+ # @return [Hash{Symbol=>Integer, Array<Object, nil>}] +{ present: Integer, values: Array }+
112
+ # for +key+ across samples
113
+ def self.observe(samples, key)
114
+ present = samples.select { |sample| sample.key?(key) }
115
+ {present: present.length, values: present.map { |sample| sample[key] }}
116
+ end
117
+ private_class_method :observe
118
+
119
+ # @param attr [String] the attribute name
120
+ # @param obs [Hash{Symbol=>Integer, Array<Object, nil>}] from {.observe}
121
+ # @param total [Integer] the sample count
122
+ # @param classes [Array<String>] class sources emitted so far
123
+ # @param used [Array<String>] class names taken so far
124
+ # @return [String] the indented DSL line
125
+ def self.field_line(attr, obs, total, classes, used)
126
+ values = obs[:values]
127
+ non_nil = values.compact
128
+ missing = values.count(&:nil?) + values.count { |v| blank_string?(v) }
129
+ required = obs[:present] == total && missing.zero?
130
+ avail = availability_comment(obs[:present], total, missing)
131
+
132
+ type_part, hint = type_for(attr, non_nil, classes, used)
133
+ decorate("#{required ? "required" : "optional"} :#{attr}, #{type_part}", avail, hint)
134
+ end
135
+ private_class_method :field_line
136
+
137
+ # @param attr [String] the attribute name
138
+ # @param non_nil [Array<Object>] the attribute's non-nil sample values
139
+ # @param classes [Array<String>] class sources emitted so far
140
+ # @param used [Array<String>] class names taken so far
141
+ # @return [Array(String, String)] +[type_part, comment_hint]+
142
+ def self.type_for(attr, non_nil, classes, used)
143
+ if non_nil.any?(::Hash)
144
+ [emit_object(class_name_for(attr), non_nil.grep(::Hash), classes, used), nil]
145
+ elsif non_nil.any?(::Array)
146
+ of_part, hint = array_element(attr, non_nil.grep(::Array).flatten(1), classes, used)
147
+ [":array, of: #{of_part}", hint]
148
+ else
149
+ sym, hint = infer_scalar(non_nil)
150
+ [":#{sym}", hint]
151
+ end
152
+ end
153
+ private_class_method :type_for
154
+
155
+ # @param attr [String] the attribute name
156
+ # @param elements [Array<Object, nil>] every element of the attribute's arrays
157
+ # @param classes [Array<String>] class sources emitted so far
158
+ # @param used [Array<String>] class names taken so far
159
+ # @return [Array(String, String)] +of:+ value and a comment hint
160
+ def self.array_element(attr, elements, classes, used)
161
+ non_nil = elements.compact
162
+ return [':value', 'empty array(s) in samples — element type unknown'] if non_nil.empty?
163
+ return [emit_object(class_name_for(singularize(attr)), non_nil.grep(::Hash), classes, used), nil] \
164
+ if non_nil.any?(::Hash)
165
+
166
+ sym, hint = infer_scalar(non_nil)
167
+ [":#{sym}", hint]
168
+ end
169
+ private_class_method :array_element
170
+
171
+ # @param values [Array<Object>] non-nil scalar sample values
172
+ # @return [Array(Symbol, String)] inferred scalar type and a comment hint
173
+ def self.infer_scalar(values)
174
+ return [:string, 'always null/absent in samples — type unknown'] if values.empty?
175
+
176
+ kinds = values.map { |v| scalar_kind(v) }.uniq
177
+ return [:boolean, nil] if kinds == [:boolean]
178
+ return [:integer, nil] if kinds == [:integer]
179
+ return [:float, nil] if (kinds - %i[integer float]).empty?
180
+ return string_inference(values) if kinds == [:string]
181
+
182
+ [:value, "mixed types in samples (#{kinds.join(", ")}) — review"]
183
+ end
184
+ private_class_method :infer_scalar
185
+
186
+ # @param values [Array<String>]
187
+ # @return [Array(Symbol, String)] +:string+ and a comment hint
188
+ def self.string_inference(values)
189
+ non_empty = values.reject { |s| blank_string?(s) }
190
+ return [:string, 'always empty in samples — type unknown'] if non_empty.empty?
191
+
192
+ if non_empty.all? { |s| s.match?(NUMERIC_STRING) }
193
+ suggestion = non_empty.any? { |s| s.include?('.') } ? ':big_decimal' : ':integer'
194
+ return [:string, %(numeric-looking (e.g. "#{non_empty.first}") — #{suggestion}? or keep :string if an id/code)]
195
+ end
196
+
197
+ distinct = non_empty.uniq
198
+ if non_empty.length >= 3 && distinct.length < non_empty.length && distinct.length <= ENUM_MAX_DISTINCT
199
+ return [:string, "values: #{distinct.inspect} — enum?"]
200
+ end
201
+
202
+ [:string, nil]
203
+ end
204
+ private_class_method :string_inference
205
+
206
+ # @param value [Object]
207
+ # @return [Symbol] +:boolean+, +:integer+, +:float+, +:string+ or +:other+
208
+ def self.scalar_kind(value)
209
+ case value
210
+ when true, false then :boolean
211
+ when ::Integer then :integer
212
+ when ::Float then :float
213
+ when ::String then :string
214
+ else :other
215
+ end
216
+ end
217
+ private_class_method :scalar_kind
218
+
219
+ # +serialize+ refuses undeclared fields, so the mapping goes after them.
220
+ #
221
+ # @param name [String] the class name
222
+ # @param mappings [Hash{String=>Object}] attribute name → original sample key
223
+ # @param lines [Array<String>] the field lines
224
+ # @return [String] the class source
225
+ def self.build_class(name, mappings, lines)
226
+ body = lines.dup
227
+ body << ' # (no fields detected in samples)' if body.empty?
228
+ unless mappings.empty?
229
+ body << ''
230
+ body << " serialize :json, #{mappings.map { |attr, key| "#{attr}: #{key.inspect}" }.join(", ")}"
231
+ end
232
+ "class #{name} < FieldStruct::Base\n#{body.join("\n")}\nend"
233
+ end
234
+ private_class_method :build_class
235
+
236
+ # @param declaration [String] the DSL call, without indentation
237
+ # @param comment_parts [String, nil] trailing-comment fragments; nil and
238
+ # empty ones are dropped
239
+ # @return [String] the indented line, with a +#+ comment when any part remains
240
+ def self.decorate(declaration, *comment_parts)
241
+ parts = comment_parts.compact.reject(&:empty?)
242
+ line = " #{declaration}"
243
+ parts.empty? ? line : "#{line} # #{parts.join("; ")}"
244
+ end
245
+ private_class_method :decorate
246
+
247
+ # @param present [Integer] samples holding the key
248
+ # @param total [Integer] the sample count
249
+ # @param missing [Integer] nil or blank values among them
250
+ # @return [String, nil]
251
+ def self.availability_comment(present, total, missing)
252
+ return "present #{present}/#{total}" if present < total
253
+ # Only note partial emptiness; when every sample is empty the scalar
254
+ # hint ("always empty — type unknown") already says so.
255
+ return "empty/null in #{missing}/#{total}" if missing.positive? && missing < total
256
+
257
+ nil
258
+ end
259
+ private_class_method :availability_comment
260
+
261
+ # First-seen union of keys across all samples.
262
+ #
263
+ # @param samples [Array<Hash{Object=>Object, nil}>]
264
+ # @return [Array<Object>]
265
+ def self.ordered_keys(samples)
266
+ samples.each_with_object([]) do |sample, keys|
267
+ sample.each_key { |key| keys << key unless keys.include?(key) }
268
+ end
269
+ end
270
+ private_class_method :ordered_keys
271
+
272
+ # @param base [String] the name wanted
273
+ # @param used [Array<String>] names taken so far (appended to)
274
+ # @return [String] +base+, or +base+ with a numeric suffix
275
+ def self.unique_name(base, used)
276
+ name = base
277
+ suffix = 1
278
+ while used.include?(name)
279
+ suffix += 1
280
+ name = "#{base}#{suffix}"
281
+ end
282
+ used << name
283
+ name
284
+ end
285
+ private_class_method :unique_name
286
+
287
+ # @param key [String] an attribute name
288
+ # @return [String] a constant-safe class name
289
+ def self.class_name_for(key)
290
+ name = camelize(key)
291
+ name = "N#{name}" unless name.match?(/\A[A-Z]/)
292
+ name
293
+ end
294
+ private_class_method :class_name_for
295
+
296
+ # @param str [String]
297
+ # @return [String]
298
+ def self.camelize(str)
299
+ str.to_s.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part| part[0].upcase + (part[1..] || '') }.join
300
+ end
301
+ private_class_method :camelize
302
+
303
+ # Naive: strip one trailing "s". Good enough for a prototype name; the
304
+ # developer renames as needed.
305
+ #
306
+ # @param str [String]
307
+ # @return [String]
308
+ def self.singularize(str)
309
+ str.end_with?('s') && str.length > 1 ? str[0..-2] : str
310
+ end
311
+ private_class_method :singularize
312
+
313
+ # @param key [Object] a sample key
314
+ # @return [String] a snake_case attribute name that +field+ accepts
315
+ def self.safe_attr(key)
316
+ attr = key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/[^a-zA-Z0-9]+/, '_').downcase
317
+ attr = attr.sub(/\A_+/, '').sub(/_+\z/, '')
318
+ attr = "field_#{attr}" if attr.empty? || attr.match?(/\A\d/)
319
+ attr = "#{attr}_field" if RESERVED.include?(attr)
320
+ attr
321
+ end
322
+ private_class_method :safe_attr
323
+
324
+ # @param value [Object, nil]
325
+ # @return [Boolean]
326
+ def self.blank_string?(value)
327
+ value.is_a?(::String) && value.strip.empty?
328
+ end
329
+ private_class_method :blank_string?
330
+ end
331
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Array type. Coerces each element through the registered element
8
+ # type named by the +:of+ DSL option (resolved at field-declaration
9
+ # time and stashed as +options[:of_type]+ — a class, not a symbol).
10
+ #
11
+ # An optional +:element+ Hash carries the element type's own options
12
+ # (+strict:+, +base:+, +format:+, …), passed to every element's coerce:
13
+ #
14
+ # optional :ids, :array, of: :integer, element: {strict: true, base: 16}
15
+ #
16
+ # A refused element is reported with its index — +element at index 2: …+ —
17
+ # and several with all of theirs (see {.elements_at}).
18
+ #
19
+ # Missing: +nil+ or empty array. ruby_type: +::Array+.
20
+ class Array < Base
21
+ # Native options: a required +of:+ naming the element type — a
22
+ # registered Symbol or a Class (a +Types::Base+ or +FieldStruct::Base+
23
+ # subclass) — and an +element:+ Hash of that element type's options.
24
+ #
25
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
26
+ def self.own_option_schema
27
+ {
28
+ of: option(type: [::Symbol, ::Class], required: true),
29
+ element: option(type: [::Hash])
30
+ }
31
+ end
32
+
33
+ # The element type class behind an +of_type+ option, whether it holds
34
+ # a class (+:integer+ → {Types::Integer}) or a built instance (a
35
+ # {Types::Nested}). Its +option_schema+ is what +element:+ accepts.
36
+ #
37
+ # @param of_type [Class, Types::Base] an array field's +:of_type+ option
38
+ # @return [Class<Types::Base>] a {Types::Base} subclass
39
+ def self.element_class(of_type)
40
+ of_type.is_a?(::Class) ? of_type : of_type.class
41
+ end
42
+
43
+ # @param value [::Array<Object, nil>, nil] anything else raises TypeError
44
+ # @param of_type [Class, Types::Base, nil] the element type — a
45
+ # Types::Base subclass for stock types, or an already-built
46
+ # instance for parameterized types like {Types::Nested}. The DSL
47
+ # stashes this when the user writes +of: :string+ / +of: SomeStruct+.
48
+ # Required at the type level; nil here means the type was called
49
+ # directly without DSL help.
50
+ # @param element [::Hash{::Symbol => Object, nil}, nil] the element
51
+ # type's options, passed to every element's coerce (the DSL's
52
+ # +element:+, already validated against the element type's schema)
53
+ # @return [::Array<Object, nil>, nil] each element coerced by +of_type+
54
+ # @raise [TypeError] when input is non-nil and not an Array
55
+ # @raise [ArgumentError] when +of_type+ is missing
56
+ # @raise [StandardError] whatever the element type raises on a bad
57
+ # element; an ArgumentError, TypeError or RangeError keeps its class
58
+ # and gains an +element at index N: + prefix — or, when several
59
+ # elements are refused, +elements at index N, M (index N shown): +
60
+ def coerce(value, of_type: nil, element: nil, **)
61
+ return nil if value.nil?
62
+ raise ::TypeError, "expected Array, got #{value.class}" unless value.is_a?(::Array)
63
+ raise ArgumentError, 'array coerce requires of_type:' if of_type.nil?
64
+
65
+ element_type = element_type_for(of_type)
66
+ # A manual index and no empty **splat keep this hot loop as cheap as it
67
+ # was before element options (each_with_index.map + **{} cost ~40%).
68
+ index = -1
69
+ value.map do |item|
70
+ index += 1
71
+ element ? element_type.coerce(item, **element) : element_type.coerce(item)
72
+ rescue ArgumentError, ::TypeError, ::RangeError => e
73
+ # Off the hot loop: only a refused array pays for finding the rest.
74
+ raise_refused!(value, index, e, element_type, element)
75
+ end
76
+ end
77
+
78
+ # How many indexes an element error lists before counting the rest.
79
+ # @return [::Integer]
80
+ INDEXES_LISTED = 5
81
+
82
+ # Name a set of element indexes for an error message: one reads
83
+ # +element at index 2+, several +elements at index 2, 5+, and past
84
+ # {INDEXES_LISTED} the rest are counted (+… and 7 more+). Indexes only,
85
+ # never elements, so a message carries no value.
86
+ #
87
+ # @param indexes [::Array<::Integer>] ascending, at least one
88
+ # @return [::String]
89
+ def self.elements_at(indexes)
90
+ return "element at index #{indexes.first}" if indexes.size == 1
91
+
92
+ listed = indexes.first(INDEXES_LISTED).join(', ')
93
+ rest = indexes.size - INDEXES_LISTED
94
+ rest.positive? ? "elements at index #{listed} and #{rest} more" : "elements at index #{listed}"
95
+ end
96
+
97
+ # @param value [Object]
98
+ # @return [Boolean] +true+ when +nil+ or empty
99
+ def missing?(value)
100
+ return true if value.nil?
101
+ return true if value.respond_to?(:empty?) && value.empty?
102
+
103
+ false
104
+ end
105
+
106
+ # @return [Class] the top-level +::Array+ class
107
+ def ruby_type
108
+ ::Array
109
+ end
110
+
111
+ private
112
+
113
+ # Coerce the elements after the first refused one, so the error names
114
+ # every refused index, then raise the first element's error with them.
115
+ # The same message reaches every coercion_policy, :raise included —
116
+ # finishing the scan costs a refused array no more than an accepted one.
117
+ #
118
+ # @param value [::Array<Object, nil>] the input array
119
+ # @param first [::Integer] the index of the first refused element
120
+ # @param error [StandardError] what the element type raised for it
121
+ # @param element_type [Types::Base] the element type instance
122
+ # @param element [::Hash{::Symbol => Object, nil}, nil] element options
123
+ # @return [void]
124
+ # @raise [StandardError] +error+'s class, prefixed with the indexes
125
+ def raise_refused!(value, first, error, element_type, element)
126
+ indexes = [first]
127
+ (first + 1).upto(value.size - 1) do |index|
128
+ item = value[index]
129
+ element ? element_type.coerce(item, **element) : element_type.coerce(item)
130
+ rescue ArgumentError, ::TypeError, ::RangeError
131
+ indexes << index
132
+ end
133
+ shown = indexes.size == 1 ? '' : " (index #{first} shown)"
134
+ # Exception#exception keeps the class without calling a custom
135
+ # initializer; the indexes, never the elements, are added. The cause
136
+ # chain stays what it was, rather than gaining the unprefixed copy.
137
+ raise error.exception("#{self.class.elements_at(indexes)}#{shown}: #{error.message}"), cause: error.cause
138
+ end
139
+
140
+ # +of_type+ may be either a Types::Base subclass (the DSL form for
141
+ # stock types like +:string+) or an already-built Types::Base
142
+ # instance (the DSL form for parameterized types like
143
+ # {Types::Nested}). Both produce a single cached instance per
144
+ # configured element type.
145
+ #
146
+ # @param type_or_instance [Class<Types::Base>, Types::Base]
147
+ # @return [Types::Base]
148
+ def element_type_for(type_or_instance)
149
+ return type_or_instance unless type_or_instance.is_a?(::Class)
150
+
151
+ @element_type_instances ||= {}
152
+ @element_type_instances[type_or_instance] ||= type_or_instance.new
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Namespace for the built-in coercion types ({Types::String}, {Types::Integer},
5
+ # …) and the {Types::Base} contract that custom types implement.
6
+ module Types
7
+ # Abstract base for all FieldStruct type classes.
8
+ #
9
+ # Subclasses must implement {#coerce} and {#ruby_type}. They may override
10
+ # {#missing?} when their notion of "missing" is broader than nil-only
11
+ # (e.g. strings consider empty/whitespace missing, arrays consider empty
12
+ # missing).
13
+ class Base
14
+ # Native per-field options this type understands, as a frozen Hash of
15
+ # option-name (Symbol) → descriptor. Each descriptor is built by
16
+ # {.option} and carries the accepted value classes, whether the option
17
+ # is required at declaration time, and any Symbol presets it accepts.
18
+ #
19
+ # This merges {.own_option_schema} down the class chain so a subtype
20
+ # inherits its parent's options for free (e.g. {Types::UUID} picks up
21
+ # {Types::String}'s +format:+ / +enum:+). The merged result is frozen and
22
+ # memoized per type class — a type's option vocabulary is fixed at load,
23
+ # so this is computed once rather than on every field declaration.
24
+ #
25
+ # This is the single source of truth for both *validation* (the DSL
26
+ # checks declared values against these descriptors) and *discoverability*
27
+ # (an agent or human can ask a type what it accepts without reading
28
+ # source). Options *not* listed here are treated as foreign and pass
29
+ # through onto the Field untouched, available to downstream tooling.
30
+ #
31
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
32
+ def self.option_schema
33
+ @option_schema ||= begin
34
+ inherited = superclass.respond_to?(:option_schema) ? superclass.option_schema : {}
35
+ inherited.merge(own_option_schema).freeze
36
+ end
37
+ end
38
+
39
+ # The options this type adds on top of its superclass's. Subclasses
40
+ # override *this* (not {.option_schema}) to declare their native options;
41
+ # the empty default means "adds nothing." {.option_schema} handles the
42
+ # inheritance merge.
43
+ #
44
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
45
+ def self.own_option_schema
46
+ {}
47
+ end
48
+
49
+ # Build a normalized descriptor for {.option_schema}.
50
+ #
51
+ # @param type [::Class, ::Array<::Class>] the Ruby class(es) a declared
52
+ # value may be an instance of
53
+ # @param required [Boolean] whether the option must be present at
54
+ # field-declaration time
55
+ # @param presets [::Array<::Symbol>] Symbol preset names the option also accepts
56
+ # @return [::Hash{::Symbol => Object}] a frozen descriptor: +{ type:, required:, presets: }+
57
+ def self.option(type:, required: false, presets: [])
58
+ {type: ::Kernel.Array(type).freeze, required: required, presets: presets.to_a.freeze}.freeze
59
+ end
60
+
61
+ # Resolve symbolic preset references on the per-field options hash
62
+ # at field-declaration time. Default implementation passes options
63
+ # through unchanged; concrete types override to interpret their
64
+ # own options (e.g. +format: :iso8601+ → strftime string).
65
+ #
66
+ # @param options [::Hash{::Symbol => Object, nil}] the per-field options hash, as written by the user
67
+ # @return [::Hash{::Symbol => Object, nil}] possibly-modified options to store on the Field
68
+ def self.resolve_options(options)
69
+ options
70
+ end
71
+
72
+ # Resolve a declared +format:+ into the form coercion and rendering use.
73
+ #
74
+ # Declared options are kept exactly as the user wrote them — see
75
+ # {TimeFormatResolver} for why — so a Symbol preset name has to be
76
+ # expanded somewhere, and the type owning the preset table is the only
77
+ # thing that can do it. This default serves types with no preset table:
78
+ # a String passes through, a Symbol is refused.
79
+ #
80
+ # @param format [::String, ::Symbol, nil] the format as declared
81
+ # @return [::String, nil]
82
+ # @raise [ArgumentError] when given a Symbol and the type has no presets
83
+ def self.resolve_format(format)
84
+ return format if format.nil? || format.is_a?(::String)
85
+
86
+ raise ArgumentError, "#{name || "this type"} has no format presets; got #{format.inspect}"
87
+ end
88
+
89
+ # Coerce an input value into the type's domain.
90
+ #
91
+ # The contract here is intentionally loose — subclasses override with
92
+ # explicit kwargs for the options they consume (e.g. +format:+ for
93
+ # Date, +round:+ for Float, +of_type:+ for Array), plus a trailing
94
+ # +**+ catch-all so a call site that splats +field.options+ can pass
95
+ # keys that some types ignore. See each subclass's +coerce+ for the
96
+ # exact accepted options.
97
+ #
98
+ # @param value [Object] raw input — each subclass narrows this to
99
+ # the inputs it actually accepts
100
+ # @return [Object] the coerced value
101
+ # @raise [NotImplementedError] always, unless overridden by a subclass
102
+ def coerce(value, **)
103
+ raise NotImplementedError, "#{self.class} must implement #coerce"
104
+ end
105
+
106
+ # Whether the given value should be treated as "missing" for the purpose
107
+ # of presence checks. The default is nil-only; subclasses override for
108
+ # emptier notions.
109
+ #
110
+ # @param value [Object]
111
+ # @return [Boolean]
112
+ def missing?(value)
113
+ value.nil?
114
+ end
115
+
116
+ # The native Ruby class a coerced value will be an instance of.
117
+ # Used by the (deferred) RBS generator and for introspection.
118
+ #
119
+ # @return [Class]
120
+ # @raise [NotImplementedError] always, unless overridden by a subclass
121
+ def ruby_type
122
+ raise NotImplementedError, "#{self.class} must implement #ruby_type"
123
+ end
124
+
125
+ # The RBS type expression {FieldStruct::RBS} emits for fields of this
126
+ # type, or +nil+ (the default) to derive one from {#ruby_type}. Override
127
+ # in a custom type whose values {#ruby_type} can't describe on its own —
128
+ # typically a generic class, which RBS needs type arguments for:
129
+ #
130
+ # def ruby_type = ::Hash
131
+ # def rbs_type = '::Hash[::Symbol, ::String]'
132
+ #
133
+ # Without it a bare generic +ruby_type+ is emitted with +untyped+
134
+ # arguments (+::Hash[untyped, untyped]+). A field's own +rbs_type:+
135
+ # option wins over this. Fully qualify constants (+::Hash+, not
136
+ # +Hash+): the RBS is emitted inside the struct's module nesting.
137
+ #
138
+ # @return [::String, nil]
139
+ def rbs_type
140
+ nil
141
+ end
142
+
143
+ # Concise type-instance representation. The default shows just the
144
+ # type-class name (e.g. +#<FieldStruct::Types::String>+). Subclasses
145
+ # that carry parameter state (Nested, Union, …) override to surface
146
+ # what they wrap.
147
+ #
148
+ # @return [::String]
149
+ def inspect
150
+ full = self.class.name || 'AnonymousType'
151
+ prefixed = full.sub(/\AFieldStruct\b/, FieldStruct.inspect_namespace)
152
+ "#<#{prefixed}>"
153
+ end
154
+
155
+ # @param pp [Object] a PP-shaped sink (responds to +#text+)
156
+ # @return [void]
157
+ def pretty_print(pp)
158
+ pp.text(inspect)
159
+ end
160
+ end
161
+ end
162
+ end