cattri 0.1.3 → 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 +41 -0
  6. data/Gemfile +12 -0
  7. data/README.md +163 -151
  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 -155
  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 -143
  62. data/lib/cattri/class_attributes.rb +0 -277
  63. data/lib/cattri/instance_attributes.rb +0 -276
  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,204 +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
- predicate: false
28
- }.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
29
52
 
30
- # Default options for instance-level attributes.
31
- DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS = {
32
- reader: true,
33
- writer: true,
34
- predicate: false
35
- }.freeze
53
+ # @!attribute [r] name
54
+ # @return [Symbol] the canonical name of the attribute
36
55
 
37
- # @return [Symbol] the attribute name
38
- attr_reader :name
56
+ # @!attribute [r] ivar
57
+ # @return [Symbol] the backing instance variable (e.g., :@enabled)
39
58
 
40
- # @return [Symbol] the attribute type (:class or :instance)
41
- attr_reader :type
59
+ # @!attribute [r] default
60
+ # @return [Proc] a callable lambda for the attribute’s default value
42
61
 
43
- # @return [Symbol] the associated instance variable (e.g., :@items)
44
- attr_reader :ivar
62
+ # @!attribute [r] transformer
63
+ # @return [Proc] a callable transformer used to process assigned values
45
64
 
46
- # @return [Symbol] the access level (:public, :protected, :private)
47
- attr_reader :access
65
+ # @!attribute [r] expose
66
+ # @return [Symbol] method exposure type (:read, :write, :read_write, or :none)
48
67
 
49
- # @return [Proc] the normalized default value block
50
- attr_reader :default
68
+ # @!attribute [r] visibility
69
+ # @return [Symbol] method visibility (:public, :protected, :private)
51
70
 
52
- # @return [Proc] the setter function used to assign values
53
- 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
54
81
 
55
- # Initializes a new attribute definition.
56
- #
57
- # @param name [String, Symbol] the name of the attribute
58
- # @param type [Symbol] either :class or :instance
59
- # @param options [Hash] additional attribute configuration
60
- # @param block [Proc, nil] optional block for setter coercion
61
- #
62
- # @raise [Cattri::UnsupportedTypeError] if an invalid type is provided
63
- def initialize(name, type, options, block)
64
- @type = type.to_sym
65
- raise Cattri::UnsupportedTypeError, type unless ATTRIBUTE_TYPES.include?(@type)
66
-
67
- @name = name.to_sym
68
- @ivar = normalize_ivar(options[:ivar])
69
- @access = options[:access] || :public
70
- @default = normalize_default(options[:default])
71
- @setter = normalize_setter(block)
72
- @options = typed_options(options)
82
+ # @return [Boolean] whether the reader should remain internal
83
+ def internal_reader?
84
+ %i[write none].include?(@options.expose)
73
85
  end
74
86
 
75
- # Hash-like access to option values or metadata.
76
- #
77
- # @param key [Symbol, String]
78
- # @return [Object]
79
- def [](key)
80
- 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)
81
90
  end
82
91
 
83
- # Serializes this attribute to a hash, including core properties and type-specific flags.
84
- #
85
- # @return [Hash]
86
- def to_hash
87
- @to_hash ||= {
88
- name: @name,
89
- ivar: @ivar,
90
- type: @type,
91
- access: @access,
92
- default: @default,
93
- setter: @setter
94
- }.merge(@options)
92
+ # @return [Boolean] whether the attribute allows reading
93
+ def readable?
94
+ %i[read read_write].include?(@options.expose)
95
95
  end
96
96
 
97
- alias to_h to_hash
97
+ # @return [Boolean] whether the attribute allows writing
98
+ def writable?
99
+ return false if @options.expose == :none
98
100
 
99
- # @return [Boolean] true if the attribute is class-scoped
100
- def class_level?
101
- type == :class
101
+ !readonly?
102
102
  end
103
103
 
104
- # @return [Boolean] true if the attribute is instance-scoped
105
- def instance_level?
106
- type == :instance
107
- end
104
+ # @return [Boolean] whether the attribute is marked readonly
105
+ def readonly?
106
+ return false if @options.expose == :none
108
107
 
109
- # @return [Boolean] whether the attribute is public
110
- def public?
111
- access == :public
108
+ @options.expose == :read || final?
112
109
  end
113
110
 
114
- # @return [Boolean] whether the attribute is protected
115
- def protected?
116
- access == :protected
111
+ # @return [Boolean] whether the attribute is marked final (write-once)
112
+ def final?
113
+ @options.final
117
114
  end
118
115
 
119
- # @return [Boolean] whether the attribute is private
120
- def private?
121
- access == :private
116
+ # @return [Boolean] whether the attribute is class-level
117
+ def class_attribute?
118
+ @options.scope == :class
122
119
  end
123
120
 
124
- # Invokes the default value logic for the attribute.
125
- #
126
- # @return [Object] the default value for the attribute
127
- # @raise [Cattri::AttributeError] if the default value logic raises an error
128
- def invoke_default
129
- default.call
130
- rescue StandardError => e
131
- 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
132
124
  end
133
125
 
134
- # Invokes the setter function with error handling
126
+ # Returns the methods that will be defined for this attribute.
135
127
  #
136
- # @param args [Array] the positional arguments
137
- # @param kwargs [Hash] the keyword arguments
138
- # @raise [Cattri::AttributeError] if setter raises an error
139
- # @return [Object] the value returned by the setter
140
- def invoke_setter(*args, **kwargs)
141
- setter.call(*args, **kwargs)
142
- rescue StandardError => e
143
- raise Cattri::AttributeError, "Failed to evaluate the setter for :#{name}. Error: #{e.message}"
144
- end
145
-
146
- private
147
-
148
- # Applies class- or instance-level defaults and filters valid option keys.
128
+ # Includes the base accessor, optional writer, and optional predicate.
149
129
  #
150
- # @param options [Hash]
151
- # @return [Hash]
152
- def typed_options(options)
153
- defaults = type == :class ? DEFAULT_CLASS_ATTRIBUTE_OPTIONS : DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS
154
- 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
155
133
  end
156
134
 
157
- # Normalizes the instance variable name for the attribute.
135
+ # Validates whether this attribute is assignable in the current context.
158
136
  #
159
- # @param ivar [String, Symbol, nil]
160
- # @return [Symbol]
161
- def normalize_ivar(ivar)
162
- ivar ||= name
163
- :"@#{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
164
144
  end
165
145
 
166
- # Returns the setter proc. If no block is provided, uses default logic:
167
- # - Returns kwargs if given
168
- # - Returns the single positional argument if one
169
- # - Returns all args as an array otherwise
146
+ # Resolves the default value for this attribute.
170
147
  #
171
- # @param block [Proc, nil]
172
- # @return [Proc]
173
- def normalize_setter(block)
174
- block || lambda { |*args, **kwargs|
175
- return kwargs unless kwargs.empty?
176
- return args.first if args.length == 1
177
-
178
- args
179
- }
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}"
180
154
  end
181
155
 
182
- # Wraps the default value in a memoized lambda.
156
+ # Processes and transforms an incoming assignment for this attribute.
183
157
  #
184
- # If value is already callable, returns it.
185
- # If immutable, wraps it in a lambda.
186
- # If mutable, wraps it in a lambda that calls `#dup`.
187
- #
188
- # @param default [Object, Proc, nil]
189
- # @return [Proc]
190
- def normalize_default(default)
191
- return default if default.respond_to?(:call)
192
- return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |type| default.is_a?(type) }
193
-
194
- lambda {
195
- begin
196
- default.dup
197
- rescue StandardError => e
198
- raise Cattri::AttributeError,
199
- "Failed to duplicate default value for :#{name}. Error: #{e.message}"
200
- end
201
- }
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}"
202
166
  end
203
167
  end
204
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