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
+ # Minimal ActiveModel::Name-shaped value for {Base.model_name}.
5
+ #
6
+ # We mirror only what Rails-adjacent callers commonly reach for:
7
+ # +name+, +singular+, +plural+, +element+, and string coercion.
8
+ # No ActiveModel code is reused; pluralization is naive +s+-suffix
9
+ # English. If a richer Name is needed, a downstream gem can build on
10
+ # this without changing the surface.
11
+ class ModelName
12
+ # @return [String] the fully-qualified class name (or "" for anonymous classes)
13
+ attr_reader :name
14
+
15
+ # @return [String] last segment, snake_case (e.g. "user_account")
16
+ attr_reader :singular
17
+
18
+ # @return [String] naive +s+-suffixed plural of {#singular}
19
+ attr_reader :plural
20
+
21
+ # @return [String] same as {#singular} (the AM convention for last segment)
22
+ attr_reader :element
23
+
24
+ # @param class_name [String, nil]
25
+ def initialize(class_name)
26
+ @name = class_name.to_s
27
+ base = @name.split('::').last.to_s
28
+ @element = camel_to_snake(base)
29
+ @singular = @element
30
+ @plural = "#{@element}s"
31
+ end
32
+
33
+ # @return [String]
34
+ def to_s
35
+ @name
36
+ end
37
+
38
+ alias to_str to_s
39
+
40
+ private
41
+
42
+ # @param str [String] a bare class name
43
+ # @return [String] its snake_case form
44
+ def camel_to_snake(str)
45
+ str.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').downcase
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Generates RBS type signatures for the *generated* instance surface of
5
+ # user-defined {FieldStruct::Base} subclasses — the per-field accessors the
6
+ # +field+ DSL defines dynamically.
7
+ #
8
+ # Sord generates +sig/field_struct.rbs+ from the library's static source, so
9
+ # it covers {Base}, {Types}, {Registry}, … but it can never see +User#name+
10
+ # or +User#age=+: those methods don't exist until a consumer declares the
11
+ # fields. This is the deferred "track 2" of the plan's RBS strategy (D13) —
12
+ # walk a subclass's {Metadata} and emit signatures for its accessors so
13
+ # downstream Steep / Solargraph can type-check code that touches them.
14
+ #
15
+ # class User < FieldStruct::Base
16
+ # required :name, :string
17
+ # optional :age, :integer
18
+ # end
19
+ #
20
+ # puts FieldStruct::RBS.generate(User)
21
+ # # # Generated by FieldStruct::RBS. ...
22
+ # # class User < ::FieldStruct::Base
23
+ # # attr_reader name: ::String
24
+ # # def name=: (::String? value) -> ::String?
25
+ # #
26
+ # # attr_reader age: ::Integer?
27
+ # # def age=: (::Integer? value) -> ::Integer?
28
+ # # end
29
+ #
30
+ # Design choices:
31
+ #
32
+ # * **Types describe a valid instance.** The reader type is the field's
33
+ # {Types::Base#ruby_type} (or an override, below). Nullability follows
34
+ # {Field#required?}: required → +T+, optional → +T?+. An invalid
35
+ # instance — a required field left unset, or a +:keep_raw+ coercion
36
+ # failure holding the raw input — can hold other values; check +valid?+
37
+ # before trusting the signature. The generated header says so.
38
+ # * **Writer** is +(T? value) -> T?+: the runtime setter accepts +nil+ and
39
+ # returns the coerced value. Loose input the type would coerce
40
+ # (+user.age = "30"+) is valid Ruby but a type error to Steep — cast or
41
+ # coerce at the boundary.
42
+ # * **Generic arguments** are filled in: arrays as +::Array[Element]+
43
+ # (a scalar element as +Element?+, since a valid instance can hold nil
44
+ # elements; a nested element stays non-nil, since nil makes it invalid),
45
+ # unions as +(A | B)+ (a boolean member as +bool+, a member's own
46
+ # {Types::Base#rbs_type} first), nested structs by qualified name; an
47
+ # anonymous nested class raises +ArgumentError+. A custom type whose +ruby_type+ is a bare generic
48
+ # class (+::Hash+) gets +untyped+ arguments unless it declares
49
+ # {Types::Base#rbs_type}.
50
+ # * **Anything-valued fields** (+:value+, or a union with a +:value+
51
+ # member) are +untyped+ — reader and writer, never +untyped?+.
52
+ # * **Overrides:** a field's +rbs_type:+ option, then the type's
53
+ # {Types::Base#rbs_type}, replace the derived type +T+ verbatim;
54
+ # nullability is still added for optional fields and writers.
55
+ # * **+new+ / +initialize+** are inherited from {Base}, which already
56
+ # accepts +User.new(name: ...)+.
57
+ #
58
+ # Each call emits one class. Referenced types (nested structs, array
59
+ # elements) appear as qualified names (+::Address+) — generate RBS for those
60
+ # classes too so the references resolve. {.type_for} exposes the per-field
61
+ # mapping for other generators.
62
+ module RBS
63
+ # The pair a +:boolean+ field's +ruby_type+ returns, rendered as +bool+.
64
+ # @return [Array<Class>]
65
+ BOOL_CLASSES = [TrueClass, FalseClass].freeze
66
+ private_constant :BOOL_CLASSES
67
+
68
+ # Comment lines opening every {.generate} result: the contract the
69
+ # signatures hold to.
70
+ # @return [String]
71
+ HEADER = <<~RBS
72
+ # Generated by FieldStruct::RBS. Types describe a valid instance: an
73
+ # invalid one (a required field left unset, or a :keep_raw coercion
74
+ # failure holding the raw input) can hold other values. Check valid?.
75
+ RBS
76
+ private_constant :HEADER
77
+
78
+ # Ruby classes RBS declares with type parameters, and how many. A bare
79
+ # +ruby_type+ naming one of these is emitted with +untyped+ arguments,
80
+ # since RBS rejects a generic class without them.
81
+ # @return [Hash{Class=>Integer}]
82
+ GENERIC_ARITY = {::Array => 1, ::Hash => 2, ::Range => 1}.freeze
83
+ private_constant :GENERIC_ARITY
84
+
85
+ # Generate RBS source for a single FieldStruct subclass.
86
+ #
87
+ # @param klass [Class<FieldStruct::Base>]
88
+ # @return [String] RBS declaring the class (wrapped in its module nesting)
89
+ # and a typed reader and writer for every field it declares, after a
90
+ # comment header stating that the types describe a valid instance
91
+ # @raise [ArgumentError] when +klass+ is not a named {FieldStruct::Base}
92
+ # subclass
93
+ def self.generate(klass)
94
+ unless klass.is_a?(::Class) && klass < FieldStruct::Base
95
+ raise ArgumentError,
96
+ "FieldStruct::RBS.generate expects a FieldStruct::Base subclass, got #{klass.inspect}"
97
+ end
98
+ raise ArgumentError, 'cannot generate RBS for an anonymous class' unless klass.name
99
+
100
+ HEADER + wrap_namespace(klass.name, class_block(klass))
101
+ end
102
+
103
+ # The RBS type of one field's value, as {.generate} emits it — for other
104
+ # generators (e.g. one typing a method that takes or returns the field)
105
+ # that need the same mapping:
106
+ #
107
+ # FieldStruct::RBS.type_for(Order.metadata[:items])
108
+ # # => "::Array[::LineItem]?"
109
+ # FieldStruct::RBS.type_for(Order.metadata[:items], nullable: false)
110
+ # # => "::Array[::LineItem]"
111
+ #
112
+ # The type comes from the field's +rbs_type:+ option, else its type's
113
+ # {Types::Base#rbs_type}, else its {Types::Base#ruby_type} with generic
114
+ # arguments filled in. Qualified names (+::LineItem+) assume the
115
+ # referenced classes get RBS too.
116
+ #
117
+ # @param field [Field] a declared field, e.g. +Order.metadata[:items]+
118
+ # @param nullable [Boolean, nil] append +?+ (the reader type of an
119
+ # optional field, and every writer's); +nil+, the default, means
120
+ # +!field.required?+. Never applied to +untyped+, which already
121
+ # admits +nil+.
122
+ # @return [String] an RBS type expression
123
+ # @raise [ArgumentError] when +field+ is not a {Field}
124
+ def self.type_for(field, nullable: nil)
125
+ unless field.is_a?(FieldStruct::Field)
126
+ raise ArgumentError, 'FieldStruct::RBS.type_for expects a FieldStruct::Field ' \
127
+ "(e.g. User.metadata[:name]), got #{field.inspect}"
128
+ end
129
+
130
+ nullable = !field.required? if nullable.nil?
131
+ base = override_expression(field.rbs_type || field.type_instance.rbs_type) ||
132
+ expression_for(field.type_instance, field.options)
133
+ nullable ? optional(base) : base
134
+ end
135
+
136
+ # Build the +class ... end+ block (unindented, no surrounding modules).
137
+ #
138
+ # @param klass [Class<FieldStruct::Base>]
139
+ # @return [String]
140
+ def self.class_block(klass)
141
+ simple = klass.name.split('::').last
142
+ lines = ["class #{simple} < ::#{klass.superclass.name}"]
143
+ own_fields(klass).each_with_index do |field, index|
144
+ writer = type_for(field, nullable: true)
145
+ lines << '' unless index.zero?
146
+ lines << " attr_reader #{field.name}: #{type_for(field)}"
147
+ lines << " def #{field.name}=: (#{writer} value) -> #{writer}"
148
+ end
149
+ lines << 'end'
150
+ lines.join("\n")
151
+ end
152
+ private_class_method :class_block
153
+
154
+ # Fields declared on +klass+ itself — inherited fields (the same {Field}
155
+ # objects merged in at inheritance time) belong to the parent's RBS.
156
+ #
157
+ # @param klass [Class<FieldStruct::Base>]
158
+ # @return [Array<Field>]
159
+ def self.own_fields(klass)
160
+ parent = klass.superclass
161
+ parent_meta = parent.respond_to?(:metadata) ? parent.metadata : nil
162
+ klass.metadata.reject { |field| parent_meta && parent_meta[field.name].equal?(field) }
163
+ end
164
+ private_class_method :own_fields
165
+
166
+ # An override, parenthesized when it is a union, intersection or proc
167
+ # type, so a trailing +?+ applies to the whole of it.
168
+ #
169
+ # @param rbs [String, nil] a field's +rbs_type:+ or a type's +rbs_type+
170
+ # @return [String, nil] +nil+ when there is no override
171
+ def self.override_expression(rbs)
172
+ return nil if rbs.nil?
173
+
174
+ rbs = rbs.strip
175
+ return rbs if wrapped?(rbs)
176
+
177
+ rbs.match?(/[|&^]/) ? "(#{rbs})" : rbs
178
+ end
179
+ private_class_method :override_expression
180
+
181
+ # @param rbs [String] a stripped RBS type expression
182
+ # @return [Boolean] whether one parenthesized group spans all of +rbs+
183
+ # (optionally followed by +?+), so wrapping it again adds nothing
184
+ def self.wrapped?(rbs)
185
+ return false unless rbs.start_with?('(')
186
+
187
+ depth = 0
188
+ rbs.each_char.with_index do |char, index|
189
+ depth += 1 if char == '('
190
+ depth -= 1 if char == ')'
191
+ return rbs[(index + 1)..].then { |rest| rest.empty? || rest == '?' } if depth.zero?
192
+ end
193
+ false
194
+ end
195
+ private_class_method :wrapped?
196
+
197
+ # @param base [String] an RBS type expression
198
+ # @return [String] +base+ made nullable, unless it already admits +nil+
199
+ def self.optional(base)
200
+ return base if base == 'untyped' || base.end_with?('?')
201
+
202
+ "#{base}?"
203
+ end
204
+ private_class_method :optional
205
+
206
+ # Map a type instance (plus its field options, needed for arrays) to an
207
+ # RBS type expression.
208
+ #
209
+ # @param type_instance [Types::Base]
210
+ # @param options [Hash{Symbol=>Object, nil}]
211
+ # @return [String]
212
+ def self.expression_for(type_instance, options)
213
+ return "::Array[#{element_type(options[:of_type])}]" if type_instance.is_a?(FieldStruct::Types::Array)
214
+ return union_expression(type_instance) if type_instance.is_a?(FieldStruct::Types::Union)
215
+
216
+ ruby_type = type_instance.ruby_type
217
+ classes = ruby_type.is_a?(::Array) ? ruby_type : [ruby_type]
218
+ mapped = classes_to_rbs(classes)
219
+ return 'untyped' if mapped.include?('untyped')
220
+
221
+ mapped.one? ? mapped.first : "(#{mapped.join(" | ")})"
222
+ end
223
+ private_class_method :expression_for
224
+
225
+ # A union as its members' types, each member's own +rbs_type+ first.
226
+ #
227
+ # @param union [Types::Union]
228
+ # @return [String] +untyped+ when any member is
229
+ def self.union_expression(union)
230
+ members = union.member_types.map { |member| override_expression(member.rbs_type) || expression_for(member, {}) }
231
+ return 'untyped' if members.include?('untyped')
232
+
233
+ members = members.uniq
234
+ members.one? ? members.first : "(#{members.join(" | ")})"
235
+ end
236
+ private_class_method :union_expression
237
+
238
+ # @param classes [Array<Class>] a {Types::Base#ruby_type}, as a list
239
+ # @return [Array<String>] one RBS type per class, with +TrueClass+ /
240
+ # +FalseClass+ together collapsed to +bool+ where the pair appears
241
+ def self.classes_to_rbs(classes)
242
+ bool = BOOL_CLASSES.all? { |klass| classes.include?(klass) }
243
+ classes.map { |klass| bool && BOOL_CLASSES.include?(klass) ? 'bool' : class_to_rbs(klass) }.uniq
244
+ end
245
+ private_class_method :classes_to_rbs
246
+
247
+ # The element type of an array field, derived from its +:of_type+ (a
248
+ # +Types::Base+ subclass for stock types, or a built instance for
249
+ # parameterized ones like {Types::Nested}).
250
+ #
251
+ # @param of_type [Class, Types::Base, nil]
252
+ # @return [String]
253
+ def self.element_type(of_type)
254
+ return 'untyped' if of_type.nil?
255
+
256
+ instance = of_type.is_a?(::Class) ? of_type.new : of_type
257
+ expression = override_expression(instance.rbs_type) || expression_for(instance, {})
258
+ # A valid instance may hold nil scalar elements; a nil nested element
259
+ # fails validation, so nested elements stay non-nil.
260
+ instance.is_a?(FieldStruct::Types::Nested) ? expression : optional(expression)
261
+ end
262
+ private_class_method :element_type
263
+
264
+ # @param klass [Class] a Ruby class from a {Types::Base#ruby_type}
265
+ # @return [String] qualified RBS name (with +untyped+ type arguments for
266
+ # a generic class), or +untyped+ for the value type's +::Object+
267
+ def self.class_to_rbs(klass)
268
+ return 'untyped' if klass == ::Object
269
+ raise ArgumentError, 'FieldStruct::RBS cannot name an anonymous class; assign it to a constant' if klass.name.nil?
270
+
271
+ arity = GENERIC_ARITY[klass]
272
+ return "::#{klass.name}" unless arity
273
+
274
+ "::#{klass.name}[#{Array.new(arity, "untyped").join(", ")}]"
275
+ end
276
+ private_class_method :class_to_rbs
277
+
278
+ # Wrap an inner block in the +module ... end+ nesting implied by a
279
+ # qualified class name, indenting two spaces per level.
280
+ #
281
+ # @param full_name [String] e.g. +"Acme::Order"+
282
+ # @param inner [String] the class block
283
+ # @return [String] RBS source ending in a trailing newline
284
+ def self.wrap_namespace(full_name, inner)
285
+ modules = full_name.split('::')[0...-1]
286
+ block = inner
287
+ modules.reverse_each { |mod| block = "module #{mod}\n#{indent(block)}\nend" }
288
+ "#{block}\n"
289
+ end
290
+ private_class_method :wrap_namespace
291
+
292
+ # @param text [String]
293
+ # @return [String] +text+ with every non-blank line indented two spaces
294
+ def self.indent(text)
295
+ text.split("\n", -1).map { |line| line.empty? ? line : " #{line}" }.join("\n")
296
+ end
297
+ private_class_method :indent
298
+ end
299
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # Name → type-class map with optional parent fallback.
5
+ #
6
+ # Holds the resolution table that maps symbolic type names (`:string`,
7
+ # `:integer`, …) to the {Types} classes that implement them. A namespace
8
+ # can build its own registry parented to the base ({FieldStruct.types})
9
+ # to extend or override the default set without mutating it.
10
+ class Registry
11
+ # @return [Registry, nil] the parent registry, or +nil+ for an unparented one
12
+ attr_reader :parent
13
+
14
+ # @param parent [Registry, nil] the registry to fall back to when a name
15
+ # isn't found locally
16
+ def initialize(parent = nil)
17
+ @parent = parent
18
+ @types = {}
19
+ end
20
+
21
+ # Register a type class — or an alias to an already-registered name.
22
+ #
23
+ # registry.register(:integer, MyInteger) # type class
24
+ # registry.register(:decimal, :big_decimal) # alias (resolved now)
25
+ #
26
+ # Aliases are eagerly resolved through {#lookup}, so the alias target
27
+ # must already be registered (locally or in a parent). A later
28
+ # registration of the same name overwrites silently — this is the
29
+ # mechanism that lets a namespace registry shadow a parent's type.
30
+ #
31
+ # @param name [Symbol, String] the name to register
32
+ # @param target [Class, Symbol] a type class, or an existing registered name
33
+ # @return [self]
34
+ # @raise [KeyError] when +target+ is a Symbol that isn't registered yet
35
+ def register(name, target)
36
+ type_class = target.is_a?(::Symbol) ? lookup(target) : target
37
+ @types[name.to_sym] = type_class
38
+ self
39
+ end
40
+
41
+ # Resolve a name to its type class, walking the parent chain.
42
+ #
43
+ # @param name [Symbol, String]
44
+ # @return [Class] the type class
45
+ # @raise [KeyError] when the name isn't registered locally or via the parent chain
46
+ def lookup(name)
47
+ key = name.to_sym
48
+ return @types[key] if @types.key?(key)
49
+ return @parent.lookup(key) if @parent
50
+
51
+ raise ::KeyError, "no FieldStruct type registered for #{name.inspect}"
52
+ end
53
+
54
+ # Whether the name resolves to anything (locally or via the parent chain).
55
+ #
56
+ # @param name [Symbol, String]
57
+ # @return [Boolean]
58
+ def key?(name)
59
+ key = name.to_sym
60
+ return true if @types.key?(key)
61
+
62
+ !@parent.nil? && @parent.key?(key)
63
+ end
64
+
65
+ # Every distinct type class resolvable through this registry, including
66
+ # those inherited from the parent chain. Local entries come first;
67
+ # duplicates (e.g. an alias pointing at an already-listed class, or a
68
+ # parent class also registered locally) are removed.
69
+ #
70
+ # @return [Array<Class>] deduplicated, local-first
71
+ def type_classes
72
+ own = @types.values
73
+ (@parent ? own + @parent.type_classes : own).uniq
74
+ end
75
+
76
+ # @return [String] one-line summary of the locally-registered names
77
+ # (does not enumerate the parent chain), with a +parent+ marker
78
+ # when the registry has one
79
+ def inspect
80
+ parts = ["types=#{@types.keys.inspect}"]
81
+ parts << 'parent' if @parent
82
+ "#<#{FieldStruct.inspect_namespace}::Registry #{parts.join(" ")}>"
83
+ end
84
+ end
85
+ end