cattri 0.1.0 → 0.1.1

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.
@@ -1,68 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "error"
4
- require_relative "helpers"
3
+ require_relative "attribute"
4
+ require_relative "context"
5
+ require_relative "attribute_definer"
6
+ require_relative "visibility"
5
7
 
6
8
  module Cattri
7
- # Provides a DSL for defining class-level attributes with support for:
9
+ # Mixin that provides support for defining class-level attributes.
8
10
  #
9
- # - Static or dynamic default values
11
+ # This module is intended to be extended onto a class and provides a DSL
12
+ # for defining configuration-style attributes at the class level using `cattr`.
13
+ #
14
+ # Features:
15
+ # - Default values (static, frozen, or callable)
10
16
  # - Optional coercion via setter blocks
11
17
  # - Optional instance-level readers
12
- # - Read-only attribute enforcement
13
- # - Inheritance-safe duplication
14
- # - Attribute locking to prevent mutation in subclasses
15
- #
16
- # This module is designed for advanced metaprogramming needs such as DSL builders,
17
- # configuration objects, and plugin systems that require reusable and introspectable
18
- # class-level state.
19
- #
20
- # @example
21
- # class MyClass
22
- # extend Cattri::ClassAttributes
23
- #
24
- # cattr :format, default: :json
25
- # cattr_reader :version, default: "1.0.0"
26
- # cattr :enabled, default: true do |value|
27
- # !!value
28
- # end
29
- # end
18
+ # - Visibility enforcement (`:public`, `:protected`, `:private`)
30
19
  #
31
- # MyClass.format # => :json
32
- # MyClass.format :xml
33
- # MyClass.format # => :xml
34
- # MyClass.version # => "1.0.0"
35
- #
36
- # instance = MyClass.new
37
- # instance.format # => :xml
20
+ # Class attributes are stored internally as `Cattri::Attribute` instances and
21
+ # values are memoized using class-level instance variables.
38
22
  module ClassAttributes
39
- include Cattri::Helpers
40
-
41
- # Default options applied to all class-level attributes.
42
- DEFAULT_OPTIONS = { default: nil, readonly: false }.freeze
43
-
44
23
  # Defines a class-level attribute with optional default, coercion, and reader access.
45
24
  #
46
25
  # @param name [Symbol] the attribute name
47
26
  # @param options [Hash] additional attribute options
48
- # @option options [Object, Proc] :default the default value or callable
27
+ # @option options [Object, Proc] :default the default value or lambda
49
28
  # @option options [Boolean] :readonly whether the attribute is read-only
50
- # @option options [Boolean] :instance_reader whether to define an instance-level reader
51
- # @yield [*args] Optional setter block for custom coercion
52
- # @raise [Cattri::Error] if attribute is already defined
53
- # @return [void]
29
+ # @option options [Boolean] :instance_reader whether to define an instance-level reader (default: true)
30
+ # @option options [Symbol] :access visibility level (:public, :protected, :private)
31
+ # @yieldparam value [Object] an optional custom setter block
32
+ # @raise [Cattri::AttributeError] or its subclasses, including `Cattri::AttributeDefinedError` or
33
+ # `Cattri::AttributeDefinitionError` if defining the attribute fails (e.g., if the attribute is
34
+ # already defined or an error occurs while defining methods)
54
35
  def class_attribute(name, **options, &block)
55
- define_inheritance unless respond_to?(:__cattri_class_attributes)
36
+ options[:access] ||= __cattri_visibility
37
+ attribute = Cattri::Attribute.new(name, :class, options, block)
38
+
39
+ raise Cattri::AttributeDefinedError, attribute if class_attribute_defined?(attribute.name)
56
40
 
57
- name, definition = define_attribute(name, options, block, DEFAULT_OPTIONS)
58
- raise Cattri::Error, "Class attribute `#{name}` already defined" if class_attribute_defined?(name)
41
+ begin
42
+ __cattri_class_attributes[name] = attribute
59
43
 
60
- __cattri_class_attributes[name] = definition
61
- define_accessor(name, definition)
62
- define_instance_reader(name) if options.fetch(:instance_reader, true)
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)
48
+ end
63
49
  end
64
50
 
65
- # Defines a read-only class attribute (no writer).
51
+ # Defines a read-only class attribute.
52
+ #
53
+ # Equivalent to calling `class_attribute(name, readonly: true, ...)`
66
54
  #
67
55
  # @param name [Symbol]
68
56
  # @param options [Hash]
@@ -71,14 +59,14 @@ module Cattri
71
59
  class_attribute(name, readonly: true, **options)
72
60
  end
73
61
 
74
- # Returns all defined class-level attribute names.
62
+ # Returns a list of defined class attribute names.
75
63
  #
76
64
  # @return [Array<Symbol>]
77
65
  def class_attributes
78
66
  __cattri_class_attributes.keys
79
67
  end
80
68
 
81
- # Checks whether a class-level attribute is defined.
69
+ # Checks whether a class attribute has been defined.
82
70
  #
83
71
  # @param name [Symbol]
84
72
  # @return [Boolean]
@@ -86,161 +74,62 @@ module Cattri
86
74
  __cattri_class_attributes.key?(name.to_sym)
87
75
  end
88
76
 
89
- # Returns metadata for a given class-level attribute.
77
+ # Returns the full attribute definition object.
90
78
  #
91
79
  # @param name [Symbol]
92
- # @return [Hash, nil]
80
+ # @return [Cattri::Attribute, nil]
93
81
  def class_attribute_definition(name)
94
82
  __cattri_class_attributes[name.to_sym]
95
83
  end
96
84
 
97
- # Resets all defined class attributes to their default values.
98
- #
99
- # @return [void]
100
- def reset_class_attributes!
101
- reset_attributes!(self, __cattri_class_attributes.values)
102
- end
103
-
104
- # Resets a single class attribute to its default value.
105
- #
106
- # @param name [Symbol]
107
- # @return [void]
108
- def reset_class_attribute!(name)
109
- definition = __cattri_class_attributes[name]
110
- return unless definition
111
-
112
- reset_attributes!(self, [definition])
113
- end
114
-
115
- # alias lock_cattrs! lock_class_attributes!
116
- # alias cattrs_locked? class_attributes_locked?
117
-
118
85
  # @!method cattr(name, **options, &block)
119
86
  # Alias for {.class_attribute}
120
- # @see .class_attribute
87
+ # @see #class_attribute
121
88
  alias cattr class_attribute
122
89
 
123
90
  # @!method cattr_accessor(name, **options, &block)
124
91
  # Alias for {.class_attribute}
125
- # @see .class_attribute
92
+ # @see #class_attribute
126
93
  alias cattr_accessor class_attribute
127
94
 
128
95
  # @!method cattr_reader(name, **options)
129
96
  # Alias for {.class_attribute_reader}
130
- # @see .class_attribute_reader
97
+ # @see #class_attribute_reader
131
98
  alias cattr_reader class_attribute_reader
132
99
 
133
100
  # @!method cattrs
134
- # @return [Array<Symbol>] all defined class attribute names
135
- # @see .class_attributes
101
+ # Alias for {.class_attributes}
102
+ # @return [Array<Symbol>]
136
103
  alias cattrs class_attributes
137
104
 
138
105
  # @!method cattr_defined?(name)
139
- # @return [Boolean] whether the given attribute has been defined
140
- # @see .class_attribute_defined?
106
+ # Alias for {.class_attribute_defined?}
107
+ # @param name [Symbol]
108
+ # @return [Boolean]
141
109
  alias cattr_defined? class_attribute_defined?
142
110
 
143
- # @!method cattr_for(name)
144
- # @return [Hash, nil] the internal metadata hash for a defined attribute
145
- # @see .class_attribute_for
111
+ # @!method cattr_definition(name)
112
+ # Alias for {.class_attribute_definition}
113
+ # @param name [Symbol]
114
+ # @return [Cattri::Attribute, nil]
146
115
  alias cattr_definition class_attribute_definition
147
116
 
148
- # @!method reset_cattrs!
149
- # Resets all class attributes to their default values.
150
- # @see .reset_class_attributes!
151
- alias reset_cattrs! reset_class_attributes!
152
-
153
- # @!method reset_cattr!(name)
154
- # Resets a specific class attribute to its default value.
155
- # @see .reset_class_attribute!
156
- alias reset_cattr! reset_class_attribute!
157
-
158
117
  private
159
118
 
160
- # Defines class-level inheritance behavior for declared attributes.
161
- #
162
- # @return [void]
163
- def define_inheritance
164
- unless singleton_class.method_defined?(:__cattri_class_attributes)
165
- define_singleton_method(:__cattri_class_attributes) { @__cattri_class_attributes ||= {} }
166
- end
167
-
168
- define_singleton_method(:inherited) do |subclass|
169
- super(subclass)
170
- subclass_attributes = {}
171
-
172
- __cattri_class_attributes.each do |name, definition|
173
- apply_attribute!(subclass, subclass_attributes, name, definition)
174
- end
175
-
176
- subclass.instance_variable_set(:@__cattri_class_attributes, subclass_attributes)
177
- end
178
- end
179
-
180
- # Defines the primary accessor method on the class.
181
- #
182
- # @param name [Symbol]
183
- # @param definition [Hash]
184
- # @return [void]
185
- def define_accessor(name, definition)
186
- ivar = definition[:ivar]
187
-
188
- define_singleton_method(name) do |*args, **kwargs|
189
- readonly = readonly_call?(args, kwargs) || definition[:readonly]
190
- return apply_readonly(ivar, definition[:default]) if readonly
191
-
192
- instance_variable_set(ivar, definition[:setter].call(*args, **kwargs))
193
- end
194
-
195
- return if definition[:readonly]
196
-
197
- define_singleton_method("#{name}=") do |value|
198
- instance_variable_set(ivar, definition[:setter].call(value))
199
- end
200
- end
201
-
202
- # Defines an instance-level reader that delegates to the class-level method.
203
- #
204
- # @param name [Symbol]
205
- # @return [void]
206
- def define_instance_reader(name)
207
- define_method(name) { self.class.__send__(name) }
208
- end
209
-
210
- # Applies the default value for a read-only call.
119
+ # Internal registry of defined class-level attributes.
211
120
  #
212
- # @param ivar [Symbol]
213
- # @param default [Proc]
214
- # @return [Object]
215
- def apply_readonly(ivar, default)
216
- return instance_variable_get(ivar) if instance_variable_defined?(ivar)
217
-
218
- value = default.call
219
- instance_variable_set(ivar, value)
121
+ # @return [Hash{Symbol => Cattri::Attribute}]
122
+ def __cattri_class_attributes
123
+ @__cattri_class_attributes ||= {}
220
124
  end
221
125
 
222
- # Applies inherited attribute definitions to a subclass.
126
+ # Context object used to define accessors with scoped visibility.
223
127
  #
224
- # @param subclass [Class]
225
- # @param attributes [Hash]
226
- # @param name [Symbol]
227
- # @param definition [Hash]
228
- # @return [void]
229
- def apply_attribute!(subclass, attributes, name, definition)
230
- value = instance_variable_get(definition[:ivar])
231
- value = value.dup rescue value # rubocop:disable Style/RescueModifier
232
-
233
- subclass.instance_variable_set(definition[:ivar], value)
234
- attributes[name] = definition
235
- end
236
-
237
- # Determines if the method call should be treated as read-only access.
238
- #
239
- # @param args [Array]
240
- # @param kwargs [Hash]
241
- # @return [Boolean]
242
- def readonly_call?(args, kwargs)
243
- args.empty? && kwargs.empty?
128
+ # @return [Cattri::Context]
129
+ # :nocov:
130
+ def context
131
+ @context ||= Context.new(self)
244
132
  end
133
+ # :nocov:
245
134
  end
246
135
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Cattri
6
+ # Provides a controlled interface for defining methods and instance variables
7
+ # on a target class or module. Used internally by Cattri to define attribute
8
+ # readers and writers while preserving access visibility and tracking which
9
+ # methods were explicitly created.
10
+ #
11
+ # This abstraction allows class and instance attribute logic to be composed
12
+ # consistently and safely across both standard and singleton contexts.
13
+ class Context
14
+ # Allowed Ruby visibility levels for methods.
15
+ ACCESS_LEVELS = %i[public protected private].freeze
16
+ private_constant :ACCESS_LEVELS
17
+
18
+ # @return [Module, Class] the receiver to which accessors will be added
19
+ attr_reader :target
20
+
21
+ # Initializes a new context wrapper around the given target.
22
+ #
23
+ # @param target [Module, Class] the object to define methods or ivars on
24
+ def initialize(target)
25
+ @target = target
26
+ @defined_methods = Set.new
27
+ end
28
+
29
+ # Returns the singleton class of the target.
30
+ #
31
+ # This is used to define methods on the class itself (not its instances).
32
+ #
33
+ # @return [Class]
34
+ def singleton
35
+ @target.singleton_class
36
+ end
37
+
38
+ # Checks whether a method is already defined on the target.
39
+ #
40
+ # This includes public, protected, private, and previously defined methods
41
+ # via this context (tracked in `@defined_methods`).
42
+ #
43
+ # @param method [String, Symbol]
44
+ # @return [Boolean]
45
+ def method_defined?(method)
46
+ @target.method_defined?(method) ||
47
+ @target.private_method_defined?(method) ||
48
+ @target.protected_method_defined?(method) ||
49
+ @defined_methods.include?(method.to_sym)
50
+ end
51
+
52
+ # Defines a method on the appropriate context (class or singleton).
53
+ #
54
+ # If the method already exists, it is not redefined. Visibility is applied
55
+ # according to the attribute's `:access` setting (defaulting to `:public`).
56
+ #
57
+ # @param attribute [Cattri::Attribute]
58
+ # @param name [Symbol, nil] optional method name override
59
+ # @yield the method implementation
60
+ # @raise [Cattri::AttributeDefinitionError] if method definition fails
61
+ # @return [void]
62
+ def define_method(attribute, name: nil, &block)
63
+ name = (name || attribute.name).to_sym
64
+ return if method_defined?(name)
65
+
66
+ target = target_for(attribute)
67
+
68
+ begin
69
+ target.define_method(name, &block)
70
+ @defined_methods << name
71
+ apply_access(name, attribute)
72
+ rescue StandardError => e
73
+ raise Cattri::AttributeDefinitionError.new(target, attribute, e)
74
+ end
75
+ end
76
+
77
+ # Checks whether the target has the given instance variable.
78
+ #
79
+ # @param name [Symbol, String]
80
+ # @return [Boolean]
81
+ def ivar_defined?(name)
82
+ @target.instance_variable_defined?(sanitize_ivar(name))
83
+ end
84
+
85
+ # Retrieves the value of the specified instance variable on the target.
86
+ #
87
+ # @param name [Symbol, String]
88
+ # @return [Object]
89
+ def ivar_get(name)
90
+ @target.instance_variable_get(sanitize_ivar(name))
91
+ end
92
+
93
+ # Assigns a value to the specified instance variable on the target.
94
+ #
95
+ # @param name [Symbol, String]
96
+ # @param value [Object]
97
+ # @return [void]
98
+ def ivar_set(name, value)
99
+ @target.instance_variable_set(sanitize_ivar(name), value)
100
+ end
101
+
102
+ # Memoizes a value in the instance variable only if not already defined.
103
+ #
104
+ # @param name [Symbol, String]
105
+ # @param value [Object]
106
+ # @return [Object] the existing or assigned value
107
+ def ivar_memoize(name, value)
108
+ return ivar_get(name) if ivar_defined?(name)
109
+
110
+ ivar_set(name, value)
111
+ end
112
+
113
+ private
114
+
115
+ # Selects the correct definition target based on attribute type.
116
+ #
117
+ # @param attribute [Cattri::Attribute]
118
+ # @return [Module] either the target or its singleton class
119
+ def target_for(attribute)
120
+ attribute.class_level? ? singleton : @target
121
+ end
122
+
123
+ # Validates and normalizes access level.
124
+ #
125
+ # @param access [Symbol, String, nil]
126
+ # @return [Symbol] a valid visibility level
127
+ def validate_access(access)
128
+ access = (access || :public).to_sym
129
+ return access if ACCESS_LEVELS.include?(access)
130
+
131
+ warn "[Cattri] `#{access.inspect}` is not a supported access level, defaulting to :public"
132
+ :public
133
+ end
134
+
135
+ # Applies method visibility to a newly defined method.
136
+ #
137
+ # @param method_name [Symbol]
138
+ # @param attribute [Cattri::Attribute]
139
+ # @return [void]
140
+ def apply_access(method_name, attribute)
141
+ return if attribute.public?
142
+
143
+ access = validate_access(attribute[:access])
144
+ Module.instance_method(access).bind(@target).call(method_name)
145
+ end
146
+
147
+ # Ensures consistent formatting for ivar keys.
148
+ #
149
+ # @param name [Symbol, String]
150
+ # @return [Symbol]
151
+ def sanitize_ivar(name)
152
+ :"@#{name.to_s.delete_prefix("@")}"
153
+ end
154
+ end
155
+ end
data/lib/cattri/error.rb CHANGED
@@ -1,5 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cattri
4
+ # Base error class for all exceptions raised by Cattri.
5
+ #
6
+ # All Cattri-specific errors inherit from this class.
7
+ #
8
+ # @example
9
+ # rescue Cattri::Error => e
10
+ # puts "Something went wrong with Cattri: #{e.message}"
4
11
  class Error < StandardError; end
12
+
13
+ # Parent class for all attribute-related errors in Cattri.
14
+ #
15
+ # This includes definition conflicts, setter failures, or configuration issues.
16
+ #
17
+ # @example
18
+ # rescue Cattri::AttributeError => e
19
+ # puts "Attribute error: #{e.message}"
20
+ class AttributeError < Cattri::Error; end
21
+
22
+ # Raised when a class or instance attribute is defined more than once.
23
+ #
24
+ # This helps detect naming collisions during DSL usage.
25
+ #
26
+ # @example
27
+ # raise Cattri::AttributeDefinedError.new(attribute)
28
+ #
29
+ # @example
30
+ # rescue Cattri::AttributeDefinedError => e
31
+ # puts e.message
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")
36
+ end
37
+ end
38
+
39
+ # Raised when a method definition (reader, writer, or callable) fails.
40
+ #
41
+ # This wraps the original error that occurred during `define_method` or visibility handling.
42
+ #
43
+ # @example
44
+ # raise Cattri::AttributeDefinitionError.new(target, attribute, error)
45
+ #
46
+ # @example
47
+ # rescue Cattri::AttributeDefinitionError => e
48
+ # puts e.message
49
+ class AttributeDefinitionError < Cattri::AttributeError
50
+ # @param target [Module] the class or module receiving the method
51
+ # @param attribute [Cattri::Attribute] the attribute being defined
52
+ # @param error [StandardError] the original raised exception
53
+ def initialize(target, attribute, error)
54
+ super("Failed to define method :#{attribute.name} on #{target}. Error: #{error.message}")
55
+ set_backtrace(error.backtrace)
56
+ end
57
+ end
58
+
59
+ # Raised when an unsupported attribute type is passed to Cattri.
60
+ #
61
+ # Valid types are typically `:class` and `:instance`. Any other value is invalid.
62
+ #
63
+ # @example
64
+ # raise Cattri::UnsupportedTypeError.new(:foo)
65
+ # # => Attribute type :foo is not supported
66
+ class UnsupportedTypeError < Error
67
+ # @param type [Symbol] the invalid type that triggered the error
68
+ def initialize(type)
69
+ super("Attribute type :#{type} is not supported")
70
+ end
71
+ end
5
72
  end