cattri 0.1.1 → 0.1.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8689b6e0fa1e741fc271fece58183191d290875fe203af5622f069aaf3c4cf7
4
- data.tar.gz: 2435ba9915a003f39d2d0275c725064c0c5c885940e7de109bb852a7258dfdd6
3
+ metadata.gz: 2495a8756d30c301267ad2db2dfbd4c9669e817119fa316b608408665988b747
4
+ data.tar.gz: 47d682eaf5465e4ab6cc4bdb7dcc74d053c93c6b8d1ee3a8cfda0925cf68662a
5
5
  SHA512:
6
- metadata.gz: 847f1396957fa8567868273f61c1906e95001e0fd8c632de72ed3f520ec4a7edb1c91c7c1f6a02101913c392d119b24a32a4d835827dab864338ab0e6b668574
7
- data.tar.gz: f7347d6cb14bb1b0501ff45799ba513388462ede2c95a47f84efb9d4a99ce7c9a6c6a8c726e605b4d475b26664c5f8861f8fc50922da4ebfbe4b139a257af131
6
+ metadata.gz: aae777877b7576a76b0be11a529aafaaac9c59066efd873fc9020689ba3ccefb78688411907b2246cb603dafa5e6868e1841e4724f296f634eac821a1bc27bc9
7
+ data.tar.gz: 8e919b6782d0cce71ed1a78bb46ebb92df995471238080a4e20604914d0fe958c041aec201eb598f9764598384fd776aaff03f5b131908082428c2d546c97cd9
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.1.3] - 2025-04-22
2
+
3
+ ### Added
4
+
5
+ - ✅ Support for `predicate: true` on both `iattr` and `cattr` — defines a `:name?` method returning `!!send(:name)`
6
+ - ✅ `iattr_alias` and `cattr_alias` — define alias methods that delegate to existing attributes (e.g., `:foo?` for `:foo`)
7
+ - Predicate methods inherit visibility from the original attribute and are excluded from introspection (`iattrs`, `cattrs`)
8
+ - Raised error when attempting to define an attribute ending in `?`, with guidance to use `predicate: true` or `*_alias`
9
+
10
+ ## [0.1.2] - 2025-04-22
11
+
12
+ ### Added
13
+
14
+ - Support for defining multiple attributes in a single call to `cattr` or `iattr`.
15
+ - Example: `cattr :foo, :bar, default: 1`
16
+ - Shared options apply to all attributes.
17
+ - Adds `cattr_setter` and `iattr_setter` for defining setters on attributes, useful when defining multiple attributes since ambiguous blocks are not allow.
18
+ ```ruby
19
+ class Config
20
+ include Cattri
21
+
22
+ cattr :a, :b # new functionality, does not allow setter blocks.
23
+ # creates writers as def a=(val); @a = val; end
24
+
25
+ cattr_setter :a do |val| # redefines a= as def a=(val); val.to_s.downcase.to_sym; end
26
+ val.to_s.downcase.to_sym
27
+ end
28
+ end
29
+ ```
30
+ - Validation to prevent use of a block when defining multiple attributes.
31
+ - Raises `Cattri::AmbiguousBlockError` if `&block` is passed with more than one attribute.
32
+
1
33
  ## [0.1.1] - 2025-04-22
2
34
 
3
35
  ### Added
data/README.md CHANGED
@@ -43,19 +43,24 @@ class Config
43
43
  include Cattri # exposes `cattr` & `iattr`
44
44
 
45
45
  # -- class‑level ----------------------------------
46
- cattr :enabled, default: true
47
- cattr :timeout, default: -> { 5.0 }, instance_reader: false
46
+ cattr :flag_a, :flag_b, default: true
47
+ cattr :enabled, default: true, predicate: true
48
+ cattr :timeout, default: -> { 5.0 }, instance_reader: false
48
49
 
49
50
  # -- instance‑level -------------------------------
50
- iattr :name, default: "anonymous"
51
- iattr :age, default: 0 do |val| # coercion block
51
+ iattr :item_a, :item_b, default: true
52
+ iattr :name, default: "anonymous"
53
+ iattr_alias :username, :name
54
+ iattr :age, default: 0 do |val| # coercion block
52
55
  Integer(val)
53
56
  end
54
57
  end
55
58
 
56
59
  Config.enabled # => true
57
60
  Config.enabled = false
61
+ Config.enabled? # => false (created with predicate: true flag)
58
62
  Config.new.age = "42" # => 42
63
+ Config.new.username # proxy to Config.new.name
59
64
  ```
60
65
 
61
66
  ---
@@ -65,12 +70,14 @@ Config.new.age = "42" # => 42
65
70
  ### Class attributes (`cattr`)
66
71
 
67
72
  ```ruby
68
- cattr :log_level, default: :info,
69
- access: :protected, # respects current visibility by default
70
- readonly: false,
71
- instance_reader: true do |value|
72
- value.to_sym
73
- end
73
+ cattr :log_level,
74
+ default: :info,
75
+ access: :protected, # respects current visibility by default
76
+ readonly: false,
77
+ predicate: true, # defines #{name}? predicate method that respects visibility
78
+ instance_reader: true do |value|
79
+ value.to_sym
80
+ end
74
81
  ```
75
82
 
76
83
  ### Instance attributes (`iattr`)
@@ -78,7 +85,8 @@ cattr :log_level, default: :info,
78
85
  ```ruby
79
86
  iattr :token, default: -> { SecureRandom.hex(8) },
80
87
  reader: true,
81
- writer: false # read‑only
88
+ writer: false, # read‑only
89
+ predicate: true
82
90
  ```
83
91
 
84
92
  Both forms accept:
@@ -90,11 +98,54 @@ Both forms accept:
90
98
  | `reader:` / `writer:` | Disable reader or writer for instance attributes. |
91
99
  | `readonly:` | Shorthand for class attributes (`writer` is always present). |
92
100
  | `instance_reader:` | Expose class attribute as instance reader (default: **true**). |
101
+ | `predicate` | Define a `:name?` method that calls `!!send(name)`
93
102
 
94
103
  If you pass a block, it’s treated as a **coercion setter** and receives the incoming value.
95
104
 
96
105
  ---
97
106
 
107
+ ## Post-definition coercion with `*_setter`
108
+
109
+ If you define multiple attributes at once, you can't provide a coercion block inline:
110
+
111
+ ```ruby
112
+ cattr :foo, :bar, default: nil # ❌ cannot use block here
113
+ ```
114
+
115
+ Instead, define them first, then apply a coercion later using:
116
+
117
+ - `cattr_setter` for class attributes
118
+ - `iattr_setter` for instance attributes
119
+
120
+ These allow you to attach or override the setter logic after the fact:
121
+
122
+ ```ruby
123
+ class Config
124
+ include Cattri
125
+
126
+ cattr :log_level
127
+ cattr_setter :log_level do |val|
128
+ val.to_s.downcase.to_sym
129
+ end
130
+
131
+ iattr_writer :token
132
+ iattr_setter :token do |val|
133
+ val.strip
134
+ end
135
+ end
136
+ ```
137
+
138
+ Coercion is only applied when the attribute is written (via `=` or callable form), not when read.
139
+
140
+ Attempting to use `*_setter` on an undefined attribute or one without a writer will raise:
141
+
142
+ - `Cattri::AttributeNotDefinedError` – the attribute doesn't exist or wasn't fully defined
143
+ - `Cattri::AttributeDefinitionError` – the attribute is marked as readonly
144
+
145
+ These APIs ensure your DSL stays consistent and extensible, even when bulk-declaring attributes up front.
146
+
147
+ ---
148
+
98
149
  ## Visibility tracking
99
150
 
100
151
  Cattri watches calls to `public`, `protected`, and `private` while you define methods:
@@ -180,7 +231,7 @@ end
180
231
  * **ActiveSupport** extends the API but still relies on mutable class variables and offers no visibility control.
181
232
  * **Dry‑configurable** is robust yet heavyweight when you only need a handful of attributes outside a full config object.
182
233
 
183
- Cattri sits in the sweet spot: **micro‑sized (~200 LOC)**, dependency‑free, and purpose‑built for attribute declaration.
234
+ Cattri sits in the sweet spot: **micro‑sized (~300 LOC)**, dependency‑free, and purpose‑built for attribute declaration.
184
235
 
185
236
  ---
186
237
 
@@ -23,13 +23,15 @@ module Cattri
23
23
  # Default options for class-level attributes.
24
24
  DEFAULT_CLASS_ATTRIBUTE_OPTIONS = {
25
25
  readonly: false,
26
- instance_reader: true
26
+ instance_reader: true,
27
+ predicate: false
27
28
  }.freeze
28
29
 
29
30
  # Default options for instance-level attributes.
30
31
  DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS = {
31
32
  reader: true,
32
- writer: true
33
+ writer: true,
34
+ predicate: false
33
35
  }.freeze
34
36
 
35
37
  # @return [Symbol] the attribute name
@@ -47,13 +47,32 @@ module Cattri
47
47
  def define_instance_level_reader(attribute, context)
48
48
  return unless attribute.class_level?
49
49
 
50
- context.target.define_method(attribute.name) do
50
+ define_instance_level_method(attribute, context) do
51
51
  self.class.__send__(attribute.name)
52
52
  end
53
53
 
54
54
  context.send(:apply_access, attribute.name, attribute)
55
55
  end
56
56
 
57
+ # Defines an instance-level method for a class-level attribute.
58
+ #
59
+ # This is a shared utility for defining instance methods that delegate to class attributes,
60
+ # including both regular readers and predicate-style readers (`predicate: true`).
61
+ #
62
+ # Visibility is inherited from the attribute and applied to the defined method.
63
+ #
64
+ # @param attribute [Cattri::Attribute] the associated attribute metadata
65
+ # @param context [Cattri::Context] the context in which to define the method
66
+ # @param name [Symbol, nil] optional override for the method name (defaults to `attribute.name`)
67
+ # @yield the method body to define
68
+ # @return [void]
69
+ def define_instance_level_method(attribute, context, name: nil, &block)
70
+ name = (name || attribute.name).to_sym
71
+ context.target.define_method(name, &block)
72
+
73
+ context.send(:apply_access, name, attribute)
74
+ end
75
+
57
76
  # Defines standard reader and writer methods for instance-level attributes.
58
77
  #
59
78
  # Skips definition if `reader: false` or `writer: false` is specified.
@@ -92,6 +111,18 @@ module Cattri
92
111
  end
93
112
  end
94
113
 
114
+ # Defines, or redefines, a writer method (`foo=`) that sets and coerces a value via the attribute setter.
115
+ #
116
+ # @param attribute [Cattri::Attribute]
117
+ # @param context [Cattri::Context]
118
+ # @return [void]
119
+ def define_writer!(attribute, context)
120
+ context.define_method!(attribute, name: :"#{attribute.name}=") do |value|
121
+ coerced_value = attribute.setter.call(value)
122
+ instance_variable_set(attribute.ivar, coerced_value)
123
+ end
124
+ end
125
+
95
126
  private
96
127
 
97
128
  # Returns the memoized value for an attribute or computes it from the default.
@@ -20,31 +20,43 @@ module Cattri
20
20
  # Class attributes are stored internally as `Cattri::Attribute` instances and
21
21
  # values are memoized using class-level instance variables.
22
22
  module ClassAttributes
23
- # Defines a class-level attribute with optional default, coercion, and reader access.
23
+ # Defines one or more class-level attributes with optional default, coercion, and reader access.
24
24
  #
25
- # @param name [Symbol] the attribute name
25
+ # This method supports defining multiple attributes at once, provided they share the same options.
26
+ # If a block is given, only one attribute may be defined to avoid ambiguity.
27
+ #
28
+ # @example Define multiple attributes with shared options
29
+ # class_attribute :foo, :bar, default: 42
30
+ #
31
+ # @example Define a single attribute with a coercion block
32
+ # class_attribute :path do |val|
33
+ # Pathname(val)
34
+ # end
35
+ #
36
+ # @param names [Array<Symbol | String>] the names of the attributes to define
26
37
  # @param options [Hash] additional attribute options
27
38
  # @option options [Object, Proc] :default the default value or lambda
28
39
  # @option options [Boolean] :readonly whether the attribute is read-only
29
40
  # @option options [Boolean] :instance_reader whether to define an instance-level reader (default: true)
30
41
  # @option options [Symbol] :access visibility level (:public, :protected, :private)
42
+ # @option options [Boolean] :predicate whether to define a predicate-style alias method
43
+ # (e.g., `foo?`) for the attribute
31
44
  # @yieldparam value [Object] an optional custom setter block
32
45
  # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
33
46
  # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
34
47
  # already defined or an error occurs while defining methods)
35
- def class_attribute(name, **options, &block)
36
- options[:access] ||= __cattri_visibility
37
- attribute = Cattri::Attribute.new(name, :class, options, block)
48
+ # @return [void]
49
+ def class_attribute(*names, **options, &block)
50
+ raise Cattri::AmbiguousBlockError if names.size > 1 && block_given?
38
51
 
39
- raise Cattri::AttributeDefinedError, attribute if class_attribute_defined?(attribute.name)
52
+ names.each do |name|
53
+ if name.end_with?("?")
54
+ raise Cattri::AttributeError,
55
+ "Attribute names ending in '?' are not allowed. Use `predicate: true` or `cattr_alias` instead."
40
56
 
41
- begin
42
- __cattri_class_attributes[name] = attribute
57
+ end
43
58
 
44
- Cattri::AttributeDefiner.define_callable_accessor(attribute, context)
45
- Cattri::AttributeDefiner.define_instance_level_reader(attribute, context) if attribute[:instance_reader]
46
- rescue StandardError => e
47
- raise Cattri::AttributeDefinitionError.new(self, attribute, e)
59
+ define_class_attribute(name, options, block)
48
60
  end
49
61
  end
50
62
 
@@ -52,18 +64,79 @@ module Cattri
52
64
  #
53
65
  # Equivalent to calling `class_attribute(name, readonly: true, ...)`
54
66
  #
55
- # @param name [Symbol]
56
- # @param options [Hash]
67
+ # @param names [Array<Symbol | String>] the names of the attributes to define
68
+ # @param options [Hash] additional attribute options
69
+ # @option options [Object, Proc] :default the default value or lambda
70
+ # @option options [Boolean] :readonly whether the attribute is read-only
71
+ # @option options [Boolean] :instance_reader whether to define an instance-level reader (default: true)
72
+ # @option options [Symbol] :access visibility level (:public, :protected, :private)
73
+ # @option options [Boolean] :predicate whether to define a predicate-style alias method
74
+ # (e.g., `foo?`) for the attribute
75
+ # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
76
+ # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
77
+ # already defined or an error occurs while defining methods)
78
+ # @return [void]
79
+ def class_attribute_reader(*names, **options)
80
+ class_attribute(*names, **options, readonly: true)
81
+ end
82
+
83
+ # Updates the setter behavior of an existing class-level attribute.
84
+ #
85
+ # This allows coercion logic to be defined or overridden after the attribute
86
+ # has been declared using `cattr`, as long as the writer method exists.
87
+ #
88
+ # @example Add coercion to an existing attribute
89
+ # cattr :format
90
+ # cattr_setter :format do |val|
91
+ # val.to_s.downcase.to_sym
92
+ # end
93
+ #
94
+ # @param name [Symbol, String] the name of the attribute
95
+ # @yieldparam value [Object] the value passed to the setter
96
+ # @yieldreturn [Object] the coerced value to be assigned
97
+ # @raise [Cattri::AttributeNotDefinedError] if the attribute is not defined or the writer method does not exist
98
+ # @raise [Cattri::AttributeDefinitionError] if method redefinition fails
99
+ # @return [void]
100
+ def class_attribute_setter(name, &block)
101
+ name = name.to_sym
102
+ attribute = __cattri_class_attributes[name]
103
+ puts "<<< #{attribute} = #{name}>"
104
+
105
+ if attribute.nil? || !context.method_defined?(:"#{name}=")
106
+ raise Cattri::AttributeNotDefinedError.new(:class, name)
107
+ end
108
+
109
+ attribute.instance_variable_set(:@setter, attribute.send(:normalize_setter, block))
110
+ Cattri::AttributeDefiner.define_writer!(attribute, context)
111
+ end
112
+
113
+ # Defines an alias method for an existing class-level attribute.
114
+ #
115
+ # This does **not** register a new attribute; it simply defines a method
116
+ # (e.g., a predicate-style alias like `foo?`) that delegates to an existing one.
117
+ #
118
+ # The alias method inherits the visibility of the original attribute.
119
+ #
120
+ # @param alias_name [Symbol, String] the new method name (e.g., `:foo?`)
121
+ # @param original [Symbol, String] the name of the existing attribute to delegate to (e.g., `:foo`)
122
+ # @raise [Cattri::AttributeNotDefinedError] if the original attribute is not defined
57
123
  # @return [void]
58
- def class_attribute_reader(name, **options)
59
- class_attribute(name, readonly: true, **options)
124
+ def class_attribute_alias(alias_name, original)
125
+ attribute = __cattri_class_attributes[original.to_sym]
126
+ raise Cattri::AttributeNotDefinedError.new(:class, original) if attribute.nil?
127
+
128
+ context.define_method(attribute, name: alias_name) { public_send(original) }
60
129
  end
61
130
 
62
131
  # Returns a list of defined class attribute names.
63
132
  #
64
133
  # @return [Array<Symbol>]
65
134
  def class_attributes
66
- __cattri_class_attributes.keys
135
+ ([self] + ancestors + singleton_class.included_modules)
136
+ .uniq
137
+ .select { |mod| mod.respond_to?(:__cattri_class_attributes, true) }
138
+ .flat_map { |mod| mod.send(:__cattri_class_attributes).keys }
139
+ .uniq
67
140
  end
68
141
 
69
142
  # Checks whether a class attribute has been defined.
@@ -97,6 +170,16 @@ module Cattri
97
170
  # @see #class_attribute_reader
98
171
  alias cattr_reader class_attribute_reader
99
172
 
173
+ # @!method cattr_setter(name, **options)
174
+ # Alias for {.class_attribute_setter}
175
+ # @see #class_attribute_setter
176
+ alias cattr_setter class_attribute_setter
177
+
178
+ # @!method cattr_alias(name, **options)
179
+ # Alias for {.class_attribute_alias}
180
+ # @see #class_attribute_alias
181
+ alias cattr_alias class_attribute_alias
182
+
100
183
  # @!method cattrs
101
184
  # Alias for {.class_attributes}
102
185
  # @return [Array<Symbol>]
@@ -116,6 +199,65 @@ module Cattri
116
199
 
117
200
  private
118
201
 
202
+ # Defines a single class-level attribute.
203
+ #
204
+ # This is the internal implementation used by {.class_attribute} and its aliases.
205
+ # It constructs a `Cattri::Attribute`, registers it, and defines the necessary
206
+ # class and instance methods.
207
+ #
208
+ # @param name [Symbol] the name of the attribute to define
209
+ # @param options [Hash] additional attribute options (e.g., :default, :readonly)
210
+ # @param block [Proc, nil] an optional setter block for coercion
211
+ #
212
+ # @raise [Cattri::AttributeDefinedError] if the attribute has already been defined
213
+ # @raise [Cattri::AttributeDefinitionError] if method definition fails
214
+ #
215
+ # @return [void]
216
+ def define_class_attribute(name, options, block) # rubocop:disable Metrics/AbcSize
217
+ options[:access] ||= __cattri_visibility
218
+ attribute = Cattri::Attribute.new(name, :class, options, block)
219
+
220
+ raise Cattri::AttributeDefinedError.new(:class, name) if class_attribute_defined?(attribute.name)
221
+
222
+ begin
223
+ __cattri_class_attributes[name] = attribute
224
+
225
+ Cattri::AttributeDefiner.define_callable_accessor(attribute, context)
226
+ Cattri::AttributeDefiner.define_instance_level_reader(attribute, context) if attribute[:instance_reader]
227
+ rescue StandardError => e
228
+ raise Cattri::AttributeDefinitionError.new(self, attribute, e)
229
+ end
230
+
231
+ define_predicate_methods(attribute) if attribute[:predicate]
232
+ end
233
+
234
+ # Defines predicate-style (`:name?`) methods for a class-level attribute.
235
+ #
236
+ # If `attribute[:predicate]` is true, this defines a method named `:name?` that returns
237
+ # a boolean based on the truthiness of the attribute's value (`!!value`).
238
+ #
239
+ # If `attribute[:instance_reader]` is also true, an instance-level predicate method
240
+ # is defined that delegates to the class-level value.
241
+ #
242
+ # Visibility is inherited from the original attribute.
243
+ #
244
+ # @param attribute [Cattri::Attribute] the attribute for which to define predicate methods
245
+ # @return [void]
246
+ def define_predicate_methods(attribute)
247
+ return unless attribute[:predicate]
248
+
249
+ predicate_name = :"#{attribute.name}?"
250
+
251
+ # rubocop:disable Style/DoubleNegation
252
+ context.define_method(attribute, name: predicate_name) { !!send(attribute.name) }
253
+ return unless attribute[:instance_reader]
254
+
255
+ Cattri::AttributeDefiner.define_instance_level_method(attribute, context, name: predicate_name) do
256
+ !!self.class.__send__(attribute.name)
257
+ end
258
+ # rubocop:enable Style/DoubleNegation
259
+ end
260
+
119
261
  # Internal registry of defined class-level attributes.
120
262
  #
121
263
  # @return [Hash{Symbol => Cattri::Attribute}]
@@ -63,11 +63,27 @@ module Cattri
63
63
  name = (name || attribute.name).to_sym
64
64
  return if method_defined?(name)
65
65
 
66
+ define_method!(attribute, name: name, &block)
67
+ end
68
+
69
+ # Defines a method on the target class or singleton class, regardless of whether it already exists.
70
+ #
71
+ # This bypasses any checks for prior definition and forcibly installs the method using `define_method`.
72
+ # The method will be assigned visibility based on the attribute's `:access` setting.
73
+ #
74
+ # Used internally by attribute definers to (re)define writers, readers, or callables.
75
+ #
76
+ # @param attribute [Cattri::Attribute] the attribute whose context and access rules apply
77
+ # @param name [Symbol, nil] the method name to define (defaults to attribute name)
78
+ # @yield the method body to define
79
+ # @raise [Cattri::AttributeDefinitionError] if method definition fails
80
+ # @return [void]
81
+ def define_method!(attribute, name: nil, &block)
66
82
  target = target_for(attribute)
67
83
 
68
84
  begin
69
85
  target.define_method(name, &block)
70
- @defined_methods << name
86
+ @defined_methods << name unless method_defined?(name)
71
87
  apply_access(name, attribute)
72
88
  rescue StandardError => e
73
89
  raise Cattri::AttributeDefinitionError.new(target, attribute, e)
data/lib/cattri/error.rb CHANGED
@@ -30,9 +30,31 @@ module Cattri
30
30
  # rescue Cattri::AttributeDefinedError => e
31
31
  # puts e.message
32
32
  class AttributeDefinedError < Cattri::AttributeError
33
- # @param attribute [Cattri::Attribute] the conflicting attribute
34
- def initialize(attribute)
35
- super("#{attribute.type.capitalize} attribute :#{attribute.name} has already been defined")
33
+ # @param type [Symbol, String] either :class or :instance
34
+ # @param name [Symbol, String] the name of the missing attribute
35
+ def initialize(type, name)
36
+ super("#{type.capitalize} attribute :#{name} has already been defined")
37
+ end
38
+ end
39
+
40
+ # Raised when attempting to access or modify an attribute that has not been defined.
41
+ #
42
+ # This applies to both class-level and instance-level attributes.
43
+ # It is typically raised when calling `.class_attribute_setter` or `.instance_attribute_setter`
44
+ # on a name that does not exist or lacks the expected method (e.g., writer).
45
+ #
46
+ # @example
47
+ # raise Cattri::AttributeNotDefinedError.new(:class, :foo)
48
+ # # => Class attribute :foo has not been defined
49
+ #
50
+ # @example
51
+ # rescue Cattri::AttributeNotDefinedError => e
52
+ # puts e.message
53
+ class AttributeNotDefinedError < Cattri::AttributeError
54
+ # @param type [Symbol, String] either :class or :instance
55
+ # @param name [Symbol, String] the name of the missing attribute
56
+ def initialize(type, name)
57
+ super("#{type.capitalize} attribute :#{name} has not been defined")
36
58
  end
37
59
  end
38
60
 
@@ -63,10 +85,21 @@ module Cattri
63
85
  # @example
64
86
  # raise Cattri::UnsupportedTypeError.new(:foo)
65
87
  # # => Attribute type :foo is not supported
66
- class UnsupportedTypeError < Error
88
+ class UnsupportedTypeError < Cattri::AttributeError
67
89
  # @param type [Symbol] the invalid type that triggered the error
68
90
  def initialize(type)
69
91
  super("Attribute type :#{type} is not supported")
70
92
  end
71
93
  end
94
+
95
+ # Raised when a block is provided when defining a group of attributes `cattr :attr_a, :attr_b do ... end`
96
+ #
97
+ # @example
98
+ # raise Cattri::AmbiguousBlockError
99
+ # # => Cannot define multiple attributes with a block
100
+ class AmbiguousBlockError < Cattri::AttributeError
101
+ def initialize
102
+ super("Cannot define multiple attributes with a block")
103
+ end
104
+ end
72
105
  end
@@ -26,29 +26,43 @@ module Cattri
26
26
 
27
27
  # Defines instance-level attribute DSL methods.
28
28
  module ClassMethods
29
- # Defines an instance-level attribute with optional default and coercion.
29
+ # Defines one or more instance-level attributes with optional default and coercion.
30
30
  #
31
- # @param name [Symbol, String] the name of the attribute
31
+ # This method supports defining multiple attributes at once, provided they share the same options.
32
+ # If a block is given, only one attribute may be defined to avoid ambiguity.
33
+ #
34
+ # @example Define multiple attributes with shared defaults
35
+ # iattr :foo, :bar, default: []
36
+ #
37
+ # @example Define a single attribute with coercion
38
+ # iattr :level do |val|
39
+ # Integer(val)
40
+ # end
41
+ #
42
+ # @param names [Array<Symbol | String>] the names of the attributes to define
32
43
  # @param options [Hash] additional options like `:default`, `:reader`, `:writer`
33
44
  # @option options [Object, Proc] :default the default value or lambda
34
45
  # @option options [Boolean] :reader whether to define a reader method (default: true)
35
46
  # @option options [Boolean] :writer whether to define a writer method (default: true)
36
47
  # @option options [Symbol] :access method visibility (:public, :protected, :private)
48
+ # @option options [Boolean] :predicate whether to define a predicate-style alias method
49
+ # (e.g., `foo?`) for the attribute
37
50
  # @yieldparam value [Object] optional custom coercion logic for the setter
38
51
  # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
39
52
  # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
40
53
  # already defined or an error occurs while defining methods)
41
- def instance_attribute(name, **options, &block)
42
- options[:access] ||= __cattri_visibility
43
- attribute = Cattri::Attribute.new(name, :instance, options, block)
54
+ # @return [void]
55
+ def instance_attribute(*names, **options, &block)
56
+ raise Cattri::AmbiguousBlockError if names.size > 1 && block_given?
44
57
 
45
- raise Cattri::AttributeDefinedError, attribute if instance_attribute_defined?(attribute.name)
58
+ names.each do |name|
59
+ if name.end_with?("?")
60
+ raise Cattri::AttributeError,
61
+ "Attribute names ending in '?' are not allowed. Use `predicate: true` or `iattr_alias` instead."
46
62
 
47
- begin
48
- __cattri_instance_attributes[name.to_sym] = attribute
49
- Cattri::AttributeDefiner.define_accessor(attribute, context)
50
- rescue StandardError => e
51
- raise Cattri::AttributeDefinitionError.new(self, attribute, e)
63
+ end
64
+
65
+ define_instance_attribute(name, options, block)
52
66
  end
53
67
  end
54
68
 
@@ -56,30 +70,95 @@ module Cattri
56
70
  #
57
71
  # Equivalent to `instance_attribute(..., writer: false)`
58
72
  #
59
- # @param name [Symbol, String]
60
- # @param options [Hash]
73
+ # @param names [Array<Symbol | String>] the names of the attributes to define
74
+ # @param options [Hash] additional options like `:default`, `:reader`, `:writer`
75
+ # @option options [Object, Proc] :default the default value or lambda
76
+ # @option options [Boolean] :reader whether to define a reader method (default: true)
77
+ # @option options [Symbol] :access method visibility (:public, :protected, :private)
78
+ # @option options [Boolean] :predicate whether to define a predicate-style alias method
79
+ # (e.g., `foo?`) for the attribute
80
+ # @yieldparam value [Object] optional custom coercion logic for the setter
81
+ # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
82
+ # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
83
+ # already defined or an error occurs while defining methods)
61
84
  # @return [void]
62
- def instance_attribute_reader(name, **options)
63
- instance_attribute(name, writer: false, **options)
85
+ def instance_attribute_reader(*names, **options)
86
+ instance_attribute(*names, **options, writer: false)
64
87
  end
65
88
 
66
89
  # Defines a write-only instance-level attribute.
67
90
  #
68
- # Equivalent to `instance_attribute(..., reader: false)`
91
+ # Equivalent to `instance_attribute(..., reader: false)`. The predicate: option is not allowed
92
+ # when defining writer methods.
69
93
  #
70
- # @param name [Symbol, String]
71
- # @param options [Hash]
72
- # @yieldparam value [Object] optional coercion logic
94
+ # @param names [Array<Symbol | String>] the names of the attributes to define
95
+ # @param options [Hash] additional options like `:default`, `:reader`, `:writer`
96
+ # @option options [Object, Proc] :default the default value or lambda
97
+ # @option options [Boolean] :writer whether to define a writer method (default: true)
98
+ # @option options [Symbol] :access method visibility (:public, :protected, :private)
99
+ # @yieldparam value [Object] optional custom coercion logic for the setter
100
+ # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
101
+ # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
102
+ # already defined or an error occurs while defining methods)
73
103
  # @return [void]
74
- def instance_attribute_writer(name, **options, &block)
75
- instance_attribute(name, reader: false, **options, &block)
104
+ def instance_attribute_writer(*names, **options, &block)
105
+ instance_attribute(*names, **options.merge(reader: false, predicate: false), &block)
106
+ end
107
+
108
+ # Updates the setter behavior of an existing instance-level attribute.
109
+ #
110
+ # This allows coercion logic to be defined or overridden after the attribute
111
+ # has been declared using `iattr`, as long as the writer method exists.
112
+ #
113
+ # @example Add coercion to an existing attribute
114
+ # iattr :format
115
+ # iattr_setter :format do |val|
116
+ # val.to_s.downcase.to_sym
117
+ # end
118
+ #
119
+ # @param name [Symbol, String] the name of the attribute
120
+ # @yieldparam value [Object] the value passed to the setter
121
+ # @yieldreturn [Object] the coerced value to be assigned
122
+ # @raise [Cattri::AttributeNotDefinedError] if the attribute is not defined or the writer method does not exist
123
+ # @raise [Cattri::AttributeDefinitionError] if method redefinition fails
124
+ # @return [void]
125
+ def instance_attribute_setter(name, &block)
126
+ attribute = __cattri_instance_attributes[name.to_sym]
127
+
128
+ raise Cattri::AttributeNotDefinedError.new(:instance, name) if attribute.nil?
129
+ raise Cattri::AttributeError, "Cannot define setter for readonly attribute :#{name}" unless attribute[:writer]
130
+
131
+ attribute.instance_variable_set(:@setter, attribute.send(:normalize_setter, block))
132
+ Cattri::AttributeDefiner.define_writer!(attribute, context)
133
+ end
134
+
135
+ # Defines an alias method for an existing instance-level attribute.
136
+ #
137
+ # This does **not** register a new attribute; it simply defines a method
138
+ # (e.g., a predicate-style alias like `foo?`) that delegates to an existing one.
139
+ #
140
+ # The alias method inherits the visibility of the original attribute.
141
+ #
142
+ # @param alias_name [Symbol, String] the new method name (e.g., `:foo?`)
143
+ # @param original [Symbol, String] the name of the existing attribute to delegate to (e.g., `:foo`)
144
+ # @raise [Cattri::AttributeNotDefinedError] if the original attribute is not defined
145
+ # @return [void]
146
+ def instance_attribute_alias(alias_name, original)
147
+ attribute = __cattri_instance_attributes[original.to_sym]
148
+ raise Cattri::AttributeNotDefinedError.new(:instance, original) if attribute.nil?
149
+
150
+ context.define_method(attribute, name: alias_name) { public_send(original) }
76
151
  end
77
152
 
78
153
  # Returns a list of defined instance-level attribute names.
79
154
  #
80
155
  # @return [Array<Symbol>]
81
156
  def instance_attributes
82
- __cattri_instance_attributes.keys
157
+ ([self] + ancestors + singleton_class.included_modules)
158
+ .uniq
159
+ .select { |mod| mod.respond_to?(:__cattri_instance_attributes, true) }
160
+ .flat_map { |mod| mod.send(:__cattri_instance_attributes).keys }
161
+ .uniq
83
162
  end
84
163
 
85
164
  # Checks if an instance-level attribute has been defined.
@@ -100,34 +179,81 @@ module Cattri
100
179
 
101
180
  # @!method iattr(name, **options, &block)
102
181
  # Alias for {#instance_attribute}
182
+ # @see #instance_attribute
103
183
  alias iattr instance_attribute
104
184
 
105
185
  # @!method iattr_accessor(name, **options, &block)
106
186
  # Alias for {#instance_attribute}
187
+ # @see #instance_attribute
107
188
  alias iattr_accessor instance_attribute
108
189
 
109
190
  # @!method iattr_reader(name, **options)
110
191
  # Alias for {#instance_attribute_reader}
192
+ # @see #instance_attribute_reader
111
193
  alias iattr_reader instance_attribute_reader
112
194
 
113
195
  # @!method iattr_writer(name, **options, &block)
114
196
  # Alias for {#instance_attribute_writer}
197
+ # @see #instance_attribute_writer
115
198
  alias iattr_writer instance_attribute_writer
116
199
 
200
+ # @!method iattr_setter(name, &block)
201
+ # Alias for {#instance_attribute_setter}
202
+ # @see #instance_attribute_setter
203
+ alias iattr_setter instance_attribute_setter
204
+
205
+ # @!method iattr_alias(name, &block)
206
+ # Alias for {#instance_attribute_alias}
207
+ # @see #instance_attribute_alias
208
+ alias iattr_alias instance_attribute_alias
209
+
117
210
  # @!method iattrs
118
211
  # Alias for {#instance_attributes}
212
+ # @see #instance_attributes
119
213
  alias iattrs instance_attributes
120
214
 
121
215
  # @!method iattr_defined?(name)
122
216
  # Alias for {#instance_attribute_defined?}
217
+ # @see #instance_attribute_defined?
123
218
  alias iattr_defined? instance_attribute_defined?
124
219
 
125
220
  # @!method iattr_definition(name)
126
221
  # Alias for {#instance_attribute_definition}
222
+ # @see #instance_attribute_definition
127
223
  alias iattr_definition instance_attribute_definition
128
224
 
129
225
  private
130
226
 
227
+ # Defines a single instance-level attribute.
228
+ #
229
+ # This is the internal implementation used by {.instance_attribute} and its aliases.
230
+ # It creates a `Cattri::Attribute`, registers it, and defines the appropriate
231
+ # reader and/or writer methods on the class.
232
+ #
233
+ # @param name [Symbol, String] the attribute name
234
+ # @param options [Hash] additional options for the attribute
235
+ # @param block [Proc, nil] optional setter coercion logic
236
+ #
237
+ # @raise [Cattri::AttributeDefinedError] if the attribute has already been defined
238
+ # @raise [Cattri::AttributeDefinitionError] if method definition fails
239
+ #
240
+ # @return [void]
241
+ def define_instance_attribute(name, options, block) # rubocop:disable Metrics/AbcSize
242
+ options[:access] ||= __cattri_visibility
243
+ attribute = Cattri::Attribute.new(name, :instance, options, block)
244
+
245
+ raise Cattri::AttributeDefinedError.new(:instance, name) if instance_attribute_defined?(attribute.name)
246
+
247
+ begin
248
+ __cattri_instance_attributes[name.to_sym] = attribute
249
+ Cattri::AttributeDefiner.define_accessor(attribute, context)
250
+ rescue StandardError => e
251
+ raise Cattri::AttributeDefinitionError.new(self, attribute, e)
252
+ end
253
+
254
+ context.define_method(attribute, name: :"#{name}?") { !!send(attribute.name) } if options[:predicate]
255
+ end
256
+
131
257
  # Internal registry of instance attributes defined on the class.
132
258
  #
133
259
  # @return [Hash{Symbol => Cattri::Attribute}]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cattri
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cattri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Lucas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-22 00:00:00.000000000 Z
11
+ date: 2025-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec