cattri 0.1.2 → 0.2.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +34 -0
  3. data/.gitignore +72 -0
  4. data/.rubocop.yml +6 -3
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile +12 -0
  7. data/README.md +163 -144
  8. data/Steepfile +6 -0
  9. data/bin/console +33 -0
  10. data/bin/setup +8 -0
  11. data/cattri.gemspec +5 -5
  12. data/lib/cattri/attribute.rb +119 -153
  13. data/lib/cattri/attribute_compiler.rb +104 -0
  14. data/lib/cattri/attribute_options.rb +183 -0
  15. data/lib/cattri/attribute_registry.rb +155 -0
  16. data/lib/cattri/context.rb +124 -106
  17. data/lib/cattri/context_registry.rb +36 -0
  18. data/lib/cattri/deferred_attributes.rb +73 -0
  19. data/lib/cattri/dsl.rb +54 -0
  20. data/lib/cattri/error.rb +17 -90
  21. data/lib/cattri/inheritance.rb +35 -0
  22. data/lib/cattri/initializer_patch.rb +37 -0
  23. data/lib/cattri/internal_store.rb +104 -0
  24. data/lib/cattri/introspection.rb +56 -49
  25. data/lib/cattri/version.rb +3 -1
  26. data/lib/cattri.rb +38 -99
  27. data/sig/lib/cattri/attribute.rbs +105 -0
  28. data/sig/lib/cattri/attribute_compiler.rbs +61 -0
  29. data/sig/lib/cattri/attribute_options.rbs +150 -0
  30. data/sig/lib/cattri/attribute_registry.rbs +95 -0
  31. data/sig/lib/cattri/context.rbs +130 -0
  32. data/sig/lib/cattri/context_registry.rbs +31 -0
  33. data/sig/lib/cattri/deferred_attributes.rbs +53 -0
  34. data/sig/lib/cattri/dsl.rbs +55 -0
  35. data/sig/lib/cattri/error.rbs +28 -0
  36. data/sig/lib/cattri/inheritance.rbs +21 -0
  37. data/sig/lib/cattri/initializer_patch.rbs +26 -0
  38. data/sig/lib/cattri/internal_store.rbs +75 -0
  39. data/sig/lib/cattri/introspection.rbs +61 -0
  40. data/sig/lib/cattri/types.rbs +19 -0
  41. data/sig/lib/cattri/visibility.rbs +55 -0
  42. data/sig/lib/cattri.rbs +37 -0
  43. data/spec/cattri/attribute_compiler_spec.rb +179 -0
  44. data/spec/cattri/attribute_options_spec.rb +267 -0
  45. data/spec/cattri/attribute_registry_spec.rb +257 -0
  46. data/spec/cattri/attribute_spec.rb +297 -0
  47. data/spec/cattri/context_registry_spec.rb +45 -0
  48. data/spec/cattri/context_spec.rb +346 -0
  49. data/spec/cattri/deferred_attrributes_spec.rb +117 -0
  50. data/spec/cattri/dsl_spec.rb +69 -0
  51. data/spec/cattri/error_spec.rb +37 -0
  52. data/spec/cattri/inheritance_spec.rb +60 -0
  53. data/spec/cattri/initializer_patch_spec.rb +35 -0
  54. data/spec/cattri/internal_store_spec.rb +139 -0
  55. data/spec/cattri/introspection_spec.rb +90 -0
  56. data/spec/cattri/visibility_spec.rb +68 -0
  57. data/spec/cattri_spec.rb +54 -0
  58. data/spec/simplecov_helper.rb +21 -0
  59. data/spec/spec_helper.rb +16 -0
  60. metadata +79 -6
  61. data/lib/cattri/attribute_definer.rb +0 -124
  62. data/lib/cattri/class_attributes.rb +0 -204
  63. data/lib/cattri/instance_attributes.rb +0 -226
  64. data/sig/cattri.rbs +0 -4
data/cattri.gemspec CHANGED
@@ -16,25 +16,25 @@ Gem::Specification.new do |spec|
16
16
  spec.required_ruby_version = ">= 2.7.0"
17
17
 
18
18
  spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = "https://github.com/bnlucas/cattri"
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
20
  spec.metadata["changelog_uri"] = "https://github.com/bnlucas/cattri/blob/main/CHANGELOG.md"
21
+ spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/cattri"
21
22
  spec.metadata["rubygems_mfa_required"] = "true"
22
23
 
23
24
  spec.files = Dir.chdir(__dir__) do
24
- `git ls-files -z`.split("\x0").reject do |f|
25
- (File.expand_path(f) == __FILE__) ||
26
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
27
- end
25
+ (`git ls-files -z`.split("\x0") + %w[README.md LICENSE.txt]).uniq
28
26
  end
29
27
 
30
28
  # Runtime dependencies
31
29
  # spec.add_dependency "gem"
32
30
 
33
31
  # Development dependencies
32
+ spec.add_development_dependency "debride"
34
33
  spec.add_development_dependency "rspec"
35
34
  spec.add_development_dependency "rubocop"
36
35
  spec.add_development_dependency "simplecov"
37
36
  spec.add_development_dependency "simplecov-cobertura"
38
37
  spec.add_development_dependency "simplecov-html"
38
+ spec.add_development_dependency "steep"
39
39
  spec.add_development_dependency "yard"
40
40
  end
@@ -1,202 +1,168 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "error"
3
+ require_relative "attribute_options"
4
4
 
5
5
  module Cattri
6
- # Represents a single attribute definition in Cattri.
6
+ # @internal
7
7
  #
8
- # This class encapsulates metadata and behavior for a declared attribute,
9
- # including name, visibility, default value, and setter coercion logic.
8
+ # Attribute acts as a thin wrapper around AttributeOptions,
9
+ # exposing core attribute metadata and behavior in a safe, immutable way.
10
10
  #
11
- # It is used internally by the DSL to configure how accessors are defined,
12
- # memoized, and resolved at runtime.
11
+ # Each Attribute instance represents a single logical property,
12
+ # and delegates its behavior (default, visibility, coercion, etc.) to its associated AttributeOptions.
13
+ #
14
+ # @example
15
+ # attribute = Attribute.new(:enabled, default: true, expose: :read_write)
16
+ # attribute.name # => :enabled
17
+ # attribute.default.call # => true
18
+ # attribute.expose # => :read_write
13
19
  class Attribute
14
- # Supported attribute scopes within Cattri.
15
- ATTRIBUTE_TYPES = %i[class instance].freeze
16
-
17
- # Supported Ruby method visibility levels.
18
- ACCESS_LEVELS = %i[public protected private].freeze
20
+ # @return [Module] the class or module this attribute was defined in
21
+ attr_reader :defined_in
19
22
 
20
- # Ruby value types considered safe to reuse as-is (no `#dup` needed).
21
- SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
23
+ # Initializes a new attribute definition.
24
+ #
25
+ # @param name [Symbol, String] the attribute name
26
+ # @param defined_in [Module] the class or module where this attribute is defined
27
+ # @param options [Hash] configuration options
28
+ # @option options [Boolean] :scope whether the attribute is class-level (internally mapped to :class_attribute)
29
+ # @param transformer [Proc] optional block used to coerce/validate assigned values
30
+ def initialize(name, defined_in:, **options, &transformer)
31
+ @options = Cattri::AttributeOptions.new(name, transformer: transformer, **options)
32
+ @defined_in = defined_in
33
+ end
22
34
 
23
- # Default options for class-level attributes.
24
- DEFAULT_CLASS_ATTRIBUTE_OPTIONS = {
25
- readonly: false,
26
- instance_reader: true
27
- }.freeze
35
+ # Serializes this attribute and its configuration to a frozen hash.
36
+ #
37
+ # @return [Hash<Symbol, Object>]
38
+ def to_h
39
+ {
40
+ name: @options.name,
41
+ ivar: @options.ivar,
42
+ defined_in: @defined_in,
43
+ final: @options.final,
44
+ scope: @options.scope,
45
+ predicate: @options.predicate,
46
+ default: @options.default,
47
+ transformer: @options.transformer,
48
+ expose: @options.expose,
49
+ visibility: @options.visibility
50
+ }
51
+ end
28
52
 
29
- # Default options for instance-level attributes.
30
- DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS = {
31
- reader: true,
32
- writer: true
33
- }.freeze
53
+ # @!attribute [r] name
54
+ # @return [Symbol] the canonical name of the attribute
34
55
 
35
- # @return [Symbol] the attribute name
36
- attr_reader :name
56
+ # @!attribute [r] ivar
57
+ # @return [Symbol] the backing instance variable (e.g., :@enabled)
37
58
 
38
- # @return [Symbol] the attribute type (:class or :instance)
39
- attr_reader :type
59
+ # @!attribute [r] default
60
+ # @return [Proc] a callable lambda for the attribute’s default value
40
61
 
41
- # @return [Symbol] the associated instance variable (e.g., :@items)
42
- attr_reader :ivar
62
+ # @!attribute [r] transformer
63
+ # @return [Proc] a callable transformer used to process assigned values
43
64
 
44
- # @return [Symbol] the access level (:public, :protected, :private)
45
- attr_reader :access
65
+ # @!attribute [r] expose
66
+ # @return [Symbol] method exposure type (:read, :write, :read_write, or :none)
46
67
 
47
- # @return [Proc] the normalized default value block
48
- attr_reader :default
68
+ # @!attribute [r] visibility
69
+ # @return [Symbol] method visibility (:public, :protected, :private)
49
70
 
50
- # @return [Proc] the setter function used to assign values
51
- attr_reader :setter
71
+ %i[
72
+ name
73
+ ivar
74
+ default
75
+ transformer
76
+ expose
77
+ visibility
78
+ ].each do |option|
79
+ define_method(option) { @options.public_send(option) }
80
+ end
52
81
 
53
- # Initializes a new attribute definition.
54
- #
55
- # @param name [String, Symbol] the name of the attribute
56
- # @param type [Symbol] either :class or :instance
57
- # @param options [Hash] additional attribute configuration
58
- # @param block [Proc, nil] optional block for setter coercion
59
- #
60
- # @raise [Cattri::UnsupportedTypeError] if an invalid type is provided
61
- def initialize(name, type, options, block)
62
- @type = type.to_sym
63
- raise Cattri::UnsupportedTypeError, type unless ATTRIBUTE_TYPES.include?(@type)
64
-
65
- @name = name.to_sym
66
- @ivar = normalize_ivar(options[:ivar])
67
- @access = options[:access] || :public
68
- @default = normalize_default(options[:default])
69
- @setter = normalize_setter(block)
70
- @options = typed_options(options)
82
+ # @return [Boolean] whether the reader should remain internal
83
+ def internal_reader?
84
+ %i[write none].include?(@options.expose)
71
85
  end
72
86
 
73
- # Hash-like access to option values or metadata.
74
- #
75
- # @param key [Symbol, String]
76
- # @return [Object]
77
- def [](key)
78
- to_hash[key.to_sym]
87
+ # @return [Boolean] whether the writer should remain internal
88
+ def internal_writer?
89
+ %i[read none].include?(@options.expose)
79
90
  end
80
91
 
81
- # Serializes this attribute to a hash, including core properties and type-specific flags.
82
- #
83
- # @return [Hash]
84
- def to_hash
85
- @to_hash ||= {
86
- name: @name,
87
- ivar: @ivar,
88
- type: @type,
89
- access: @access,
90
- default: @default,
91
- setter: @setter
92
- }.merge(@options)
92
+ # @return [Boolean] whether the attribute allows reading
93
+ def readable?
94
+ %i[read read_write].include?(@options.expose)
93
95
  end
94
96
 
95
- alias to_h to_hash
97
+ # @return [Boolean] whether the attribute allows writing
98
+ def writable?
99
+ return false if @options.expose == :none
96
100
 
97
- # @return [Boolean] true if the attribute is class-scoped
98
- def class_level?
99
- type == :class
101
+ !readonly?
100
102
  end
101
103
 
102
- # @return [Boolean] true if the attribute is instance-scoped
103
- def instance_level?
104
- type == :instance
105
- end
104
+ # @return [Boolean] whether the attribute is marked readonly
105
+ def readonly?
106
+ return false if @options.expose == :none
106
107
 
107
- # @return [Boolean] whether the attribute is public
108
- def public?
109
- access == :public
108
+ @options.expose == :read || final?
110
109
  end
111
110
 
112
- # @return [Boolean] whether the attribute is protected
113
- def protected?
114
- access == :protected
111
+ # @return [Boolean] whether the attribute is marked final (write-once)
112
+ def final?
113
+ @options.final
115
114
  end
116
115
 
117
- # @return [Boolean] whether the attribute is private
118
- def private?
119
- access == :private
116
+ # @return [Boolean] whether the attribute is class-level
117
+ def class_attribute?
118
+ @options.scope == :class
120
119
  end
121
120
 
122
- # Invokes the default value logic for the attribute.
123
- #
124
- # @return [Object] the default value for the attribute
125
- # @raise [Cattri::AttributeError] if the default value logic raises an error
126
- def invoke_default
127
- default.call
128
- rescue StandardError => e
129
- raise Cattri::AttributeError, "Failed to evaluate the default value for :#{name}. Error: #{e.message}"
121
+ # @return [Boolean] whether the attribute defines a predicate method (`:name?`)
122
+ def with_predicate?
123
+ @options.predicate
130
124
  end
131
125
 
132
- # Invokes the setter function with error handling
126
+ # Returns the methods that will be defined for this attribute.
133
127
  #
134
- # @param args [Array] the positional arguments
135
- # @param kwargs [Hash] the keyword arguments
136
- # @raise [Cattri::AttributeError] if setter raises an error
137
- # @return [Object] the value returned by the setter
138
- def invoke_setter(*args, **kwargs)
139
- setter.call(*args, **kwargs)
140
- rescue StandardError => e
141
- raise Cattri::AttributeError, "Failed to evaluate the setter for :#{name}. Error: #{e.message}"
142
- end
143
-
144
- private
145
-
146
- # Applies class- or instance-level defaults and filters valid option keys.
128
+ # Includes the base accessor, optional writer, and optional predicate.
147
129
  #
148
- # @param options [Hash]
149
- # @return [Hash]
150
- def typed_options(options)
151
- defaults = type == :class ? DEFAULT_CLASS_ATTRIBUTE_OPTIONS : DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS
152
- defaults.merge(options.slice(*defaults.keys))
130
+ # @return [Array<Symbol>] a list of method names
131
+ def allowed_methods
132
+ [name, (:"#{name}=" if writable?), (:"#{name}?" if with_predicate?)].compact.freeze
153
133
  end
154
134
 
155
- # Normalizes the instance variable name for the attribute.
135
+ # Validates whether this attribute is assignable in the current context.
156
136
  #
157
- # @param ivar [String, Symbol, nil]
158
- # @return [Symbol]
159
- def normalize_ivar(ivar)
160
- ivar ||= name
161
- :"@#{ivar.to_s.delete_prefix("@")}"
137
+ # @raise [Cattri::AttributeError] if assignment is disallowed
138
+ def validate_assignment!
139
+ if final?
140
+ raise Cattri::AttributeError, "Cannot assign to final attribute `:#{name}`"
141
+ elsif readonly?
142
+ raise Cattri::AttributeError, "Cannot assign to readonly attribute `:#{name}`"
143
+ end
162
144
  end
163
145
 
164
- # Returns the setter proc. If no block is provided, uses default logic:
165
- # - Returns kwargs if given
166
- # - Returns the single positional argument if one
167
- # - Returns all args as an array otherwise
146
+ # Resolves the default value for this attribute.
168
147
  #
169
- # @param block [Proc, nil]
170
- # @return [Proc]
171
- def normalize_setter(block)
172
- block || lambda { |*args, **kwargs|
173
- return kwargs unless kwargs.empty?
174
- return args.first if args.length == 1
175
-
176
- args
177
- }
148
+ # @return [Object] the evaluated default
149
+ # @raise [Cattri::AttributeError] if default evaluation fails
150
+ def evaluate_default
151
+ @options.default.call
152
+ rescue StandardError => e
153
+ raise Cattri::AttributeError, "Failed to evaluate the default value for `:#{@options.name}`. Error: #{e.message}"
178
154
  end
179
155
 
180
- # Wraps the default value in a memoized lambda.
156
+ # Processes and transforms an incoming assignment for this attribute.
181
157
  #
182
- # If value is already callable, returns it.
183
- # If immutable, wraps it in a lambda.
184
- # If mutable, wraps it in a lambda that calls `#dup`.
185
- #
186
- # @param default [Object, Proc, nil]
187
- # @return [Proc]
188
- def normalize_default(default)
189
- return default if default.respond_to?(:call)
190
- return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |type| default.is_a?(type) }
191
-
192
- lambda {
193
- begin
194
- default.dup
195
- rescue StandardError => e
196
- raise Cattri::AttributeError,
197
- "Failed to duplicate default value for :#{name}. Error: #{e.message}"
198
- end
199
- }
158
+ # @param args [Array] positional arguments to pass to the transformer
159
+ # @param kwargs [Hash] keyword arguments to pass to the transformer
160
+ # @return [Object] the transformed value
161
+ # @raise [Cattri::AttributeError] if transformation fails
162
+ def process_assignment(*args, **kwargs)
163
+ @options.transformer.call(*args, **kwargs)
164
+ rescue StandardError => e
165
+ raise Cattri::AttributeError, "Failed to evaluate the setter for `:#{@options.name}`. Error: #{e.message}"
200
166
  end
201
167
  end
202
168
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cattri
4
+ # @internal
5
+ #
6
+ # Responsible for defining methods on the target class/module
7
+ # based on the metadata in a {Cattri::Attribute}.
8
+ #
9
+ # This includes:
10
+ # - callable accessors (acting as both reader and writer)
11
+ # - predicate methods
12
+ # - explicit writers (`:name=` methods)
13
+ #
14
+ # Handles both instance and class-level attributes, including
15
+ # memoization and validation of default values for final attributes.
16
+ class AttributeCompiler
17
+ class << self
18
+ # Defines accessor methods for the given attribute in the provided context.
19
+ #
20
+ # For `final` + `scope: :class` attributes, the default is eagerly assigned.
21
+ # Then, if permitted by `expose`, the reader, writer, and/or predicate methods are defined.
22
+ #
23
+ # @param attribute [Cattri::Attribute] the attribute to define
24
+ # @param context [Cattri::Context] the target context for method definition
25
+ # @return [void]
26
+ def define_accessor(attribute, context)
27
+ if attribute.class_attribute? && attribute.final?
28
+ value = attribute.evaluate_default
29
+ context.target.cattri_variable_set(attribute.ivar, value) # steep:ignore
30
+ end
31
+
32
+ return if attribute.expose == :none
33
+
34
+ define_accessor!(attribute, context)
35
+ define_writer!(attribute, context)
36
+ define_predicate!(attribute, context) if attribute.with_predicate?
37
+ end
38
+
39
+ private
40
+
41
+ # Defines a callable method that acts as both getter and setter.
42
+ #
43
+ # If called with no arguments, it returns the default (memoized).
44
+ # If called with arguments, it processes the assignment and writes the value.
45
+ #
46
+ # @param attribute [Cattri::Attribute]
47
+ # @param context [Cattri::Context]
48
+ # @return [void]
49
+ def define_accessor!(attribute, context)
50
+ context.define_method(attribute) do |*args, **kwargs|
51
+ readonly_call = args.empty? && kwargs.empty?
52
+ return AttributeCompiler.send(:memoize_default_value, self, attribute) if readonly_call
53
+
54
+ attribute.validate_assignment!
55
+ value = attribute.process_assignment(*args, **kwargs)
56
+ cattri_variable_set(attribute.ivar, value) # steep:ignore
57
+ end
58
+ end
59
+
60
+ # Defines a writer method `:name=`, assigning a transformed value to the backing store.
61
+ #
62
+ # @param attribute [Cattri::Attribute]
63
+ # @param context [Cattri::Context]
64
+ # @return [void]
65
+ def define_writer!(attribute, context)
66
+ context.define_method(attribute, name: :"#{attribute.name}=") do |value|
67
+ coerced_value = attribute.process_assignment(value)
68
+ cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
69
+ end
70
+ end
71
+
72
+ # Defines a predicate method `:name?` that returns the truthiness of the value.
73
+ #
74
+ # @param attribute [Cattri::Attribute]
75
+ # @param context [Cattri::Context]
76
+ # @return [void]
77
+ def define_predicate!(attribute, context)
78
+ context.define_method(attribute, name: :"#{attribute.name}?") do
79
+ !!send(attribute.name) # rubocop:disable Style/DoubleNegation
80
+ end
81
+ end
82
+
83
+ # Returns the default value for the attribute, memoizing it in the backing store.
84
+ #
85
+ # For `final` attributes, raises unless explicitly initialized.
86
+ #
87
+ # @param receiver [Object] the instance or class receiving the value
88
+ # @param attribute [Cattri::Attribute]
89
+ # @return [Object] the stored or evaluated default
90
+ # @raise [Cattri::AttributeError] if final attribute is unset or evaluation fails
91
+ def memoize_default_value(receiver, attribute)
92
+ if attribute.final?
93
+ return receiver.cattri_variable_get(attribute.ivar) if receiver.cattri_variable_defined?(attribute.ivar)
94
+
95
+ raise Cattri::AttributeError, "Final attribute :#{attribute.name} cannot be written to"
96
+ end
97
+
98
+ receiver.cattri_variable_memoize(attribute.ivar, final: attribute.final?) do
99
+ attribute.evaluate_default
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Cattri
6
+ # @internal
7
+ #
8
+ # AttributeOptions encapsulates normalized metadata for a single Cattri-defined attribute.
9
+ #
10
+ # It validates, transforms, and freezes all input during initialization,
11
+ # ensuring attribute safety and immutability at runtime.
12
+ #
13
+ # @example
14
+ # options = AttributeOptions.new(:enabled, default: true, expose: :read_write)
15
+ # options.name # => :enabled
16
+ # options.default.call # => true
17
+ # options.expose # => :read_write
18
+ class AttributeOptions
19
+ class << self
20
+ # Validates and normalizes the `expose` configuration.
21
+ #
22
+ # @param expose [Symbol, String] one of: :read, :write, :read_write, :none
23
+ # @return [Symbol]
24
+ # @raise [Cattri::AttributeError] if the value is invalid
25
+ def validate_expose!(expose)
26
+ expose = expose.to_sym
27
+ return expose if EXPOSE_OPTIONS.include?(expose) # steep:ignore
28
+
29
+ raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}` for :#{name}"
30
+ end
31
+
32
+ # Validates and normalizes method visibility.
33
+ #
34
+ # @param visibility [Symbol, String] one of: :public, :protected, :private
35
+ # @return [Symbol]
36
+ # @raise [Cattri::AttributeError] if the value is invalid
37
+ def validate_visibility!(visibility)
38
+ visibility = visibility.to_sym
39
+ return visibility if VISIBILITIES.include?(visibility) # steep:ignore
40
+
41
+ raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}` for :#{name}"
42
+ end
43
+ end
44
+
45
+ # Valid method visibility levels.
46
+ VISIBILITIES = %i[public protected private].freeze
47
+
48
+ # Valid expose options for method generation.
49
+ EXPOSE_OPTIONS = %i[read write read_write none].freeze
50
+
51
+ # Valid scope types.
52
+ SCOPES = %i[class instance].freeze
53
+ private_constant :SCOPES
54
+
55
+ # Built-in Ruby value types that are safe to reuse as-is (no dup needed).
56
+ SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
57
+ private_constant :SAFE_VALUE_TYPES
58
+
59
+ attr_reader :name, :ivar, :final, :scope, :predicate,
60
+ :default, :transformer, :expose, :visibility
61
+
62
+ # Initializes a frozen attribute configuration.
63
+ #
64
+ # @param name [Symbol, String] the attribute name
65
+ # @param ivar [Symbol, String, nil] optional custom instance variable name
66
+ # @param final [Boolean] marks the attribute as write-once
67
+ # @param scope [Symbol] indicates if the attribute is class-level (:class) or instance-level (:instance)
68
+ # @param predicate [Boolean] whether to define a `?` predicate method
69
+ # @param default [Object, Proc, nil] default value or callable
70
+ # @param transformer [Proc, nil] optional coercion block
71
+ # @param expose [Symbol] access level to define (:read, :write, :read_write, :none)
72
+ # @param visibility [Symbol] method visibility (:public, :protected, :private)
73
+ def initialize(
74
+ name,
75
+ ivar: nil,
76
+ final: false,
77
+ scope: :instance,
78
+ predicate: false,
79
+ default: nil,
80
+ transformer: nil,
81
+ expose: :read_write,
82
+ visibility: :public
83
+ )
84
+ @name = name.to_sym
85
+ @ivar = normalize_ivar(ivar)
86
+ @final = final
87
+ @scope = validate_scope!(scope)
88
+ @predicate = predicate
89
+ @default = normalize_default(default)
90
+ @transformer = normalize_transformer(transformer)
91
+ @expose = self.class.validate_expose!(expose)
92
+ @visibility = self.class.validate_visibility!(visibility)
93
+
94
+ freeze
95
+ end
96
+
97
+ # Returns a frozen hash representation of this option set.
98
+ #
99
+ # @return [Hash<Symbol, Object>]
100
+ def to_h
101
+ hash = {
102
+ name: @name,
103
+ ivar: @ivar,
104
+ final: @final,
105
+ scope: @scope,
106
+ predicate: @predicate,
107
+ default: @default,
108
+ transformer: @transformer,
109
+ expose: @expose,
110
+ visibility: @visibility
111
+ }
112
+ hash.freeze
113
+ hash
114
+ end
115
+
116
+ # Allows hash-style access to the option set.
117
+ #
118
+ # @param key [Symbol, String]
119
+ # @return [Object]
120
+ def [](key)
121
+ to_h[key.to_sym]
122
+ end
123
+
124
+ private
125
+
126
+ # Normalizes the instance variable name, defaulting to @name.
127
+ #
128
+ # @param ivar [String, Symbol, nil]
129
+ # @return [Symbol]
130
+ def normalize_ivar(ivar)
131
+ ivar ||= name
132
+ :"@#{ivar.to_s.delete_prefix("@")}"
133
+ end
134
+
135
+ # Wraps the default in a Proc with immutability protection.
136
+ #
137
+ # - Returns original Proc if given.
138
+ # - Wraps immutable types as-is.
139
+ # - Duplicates mutable values at runtime.
140
+ #
141
+ # @param default [Object, Proc, nil]
142
+ # @return [Proc]
143
+ def normalize_default(default)
144
+ return default if default.is_a?(Proc)
145
+ return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |t| default.is_a?(t) }
146
+
147
+ -> { default.dup }
148
+ end
149
+
150
+ # Returns a normalized assignment transformer.
151
+ #
152
+ # Falls back to a default transformer that returns:
153
+ # - `kwargs` if `args.empty?`
154
+ # - the single argument if one is passed
155
+ # - `[*args, kwargs]` otherwise
156
+ #
157
+ # @param transformer [Proc, nil]
158
+ # @return [Proc]
159
+ def normalize_transformer(transformer)
160
+ transformer || lambda { |*args, **kwargs|
161
+ return kwargs if args.empty?
162
+ return args.length == 1 ? args[0] : args if kwargs.empty?
163
+
164
+ [*args, kwargs]
165
+ }
166
+ end
167
+
168
+ # Validates and normalizes the provided scope value.
169
+ #
170
+ # If `scope` is `nil`, it defaults to `:instance`. If it's one of the allowed
171
+ # values (`:class`, `:instance`), it is returned as-is. Otherwise, an error is raised.
172
+ #
173
+ # @param scope [Symbol, nil] the requested attribute scope
174
+ # @return [Symbol] the validated scope (`:class` or `:instance`)
175
+ # @raise [Cattri::AttributeError] if the scope is invalid
176
+ def validate_scope!(scope)
177
+ return :instance if scope.nil?
178
+ return scope if SCOPES.include?(scope)
179
+
180
+ raise Cattri::AttributeError, "Invalid scope `#{scope.inspect}` for :#{name}. Must be :class or :instance"
181
+ end
182
+ end
183
+ end