From d2e135ab5123f6bdc376313e2a2839dda4d3ea53 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Wed, 18 Feb 2026 20:49:31 -0800 Subject: [PATCH 01/14] Update Rakefile --- Rakefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Rakefile b/Rakefile index cca7175..10f9c36 100644 --- a/Rakefile +++ b/Rakefile @@ -10,3 +10,7 @@ require "rubocop/rake_task" RuboCop::RakeTask.new task default: %i[spec rubocop] + +# Require default to pass before release. This relies on the default gem release task +# (from bundler/gem_tasks) depending on "build"; default runs before build, so before push. +Rake::Task["build"].enhance([:default]) From a3ab02eca743157dd5df502358ae18a7a2f84336 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Wed, 18 Feb 2026 20:59:32 -0800 Subject: [PATCH 02/14] Improve warnings --- README.md | 12 +- .../class_methods.rb | 71 +++- spec/reader_spec.rb | 372 +++++++++++++++++- 3 files changed, 430 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ab0cac9..58e5a62 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,17 @@ We support that by passing an optional block to `initialize_with` -- for instanc * Accepting a block: this is handled automatically -- if a block was provided to the Foo.new call, it'll be made available as `@block`/`attr_reader :block` -* If a method with same name already exists, we log a warning and do not create the `attr_reader`. In that case you'll need to reference the instance variable directly. +* If a method with the same name already exists, we log a warning and do not create the `attr_reader`. In that case you'll need to reference the instance variable directly (e.g. `@foo` instead of `foo`). - * Because of this, best practice when referencing variables in the post-initialize block is to use `@foo` rather than relying on the `foo` attr_reader + * **On this class:** If you define `def foo` before calling `initialize_with :foo`, we warn and skip. + + * **Inherited from an ancestor:** If a parent class defines `def foo`, we also warn and skip. This catches the case where a subclass expects `foo` to return the init value but an ancestor has a different implementation. + + * **Exception:** If the inherited method was created by an ancestor's `initialize_with` (i.e. our `attr_reader`), we skip silently—no warning, since the behavior is the same. + + * **Caution:** If the existing method doesn't read the instance variable, `@foo` is still set but calling `foo` returns something else, which can cause subtle bugs. + + * Because of this, **best practice when referencing variables in the post-initialize block is to use `@foo` rather than relying on the `foo` attr_reader** * Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration: diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index 3726820..a836a35 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "logger" +require "set" module DeclarativeInitialization module ClassMethods @@ -39,22 +40,76 @@ def _validate_arguments!(declared) raise ArgumentError, "[#{_class_name}] All arguments to #initialize_with must be symbols" end + def _declarative_initialization_readers + @_declarative_initialization_readers ||= Set.new + end + + def _reader_defined_by_us?(key) + _declarative_initialization_readers.include?(key) + end + + def _ancestor_with_reader(key) + ancestors.drop(1).find do |ancestor| + ancestor.instance_variable_defined?(:@_declarative_initialization_readers) && + ancestor.instance_variable_get(:@_declarative_initialization_readers).include?(key) + end + end + + def _ancestor_name(ancestor) + ancestor.name || "an anonymous ancestor" + end + + def _method_owner_name(key) + owner = instance_method(key).owner + owner.name || "an anonymous ancestor" + end + def _set_up_attribute_readers(declared) declared.each do |key| - if method_defined?(key) - _logger.warn "[#{_class_name}] Method ##{key} already exists -- skipping attr_reader generation" + _define_reader_if_needed(key) + end + end + + def _set_up_block_reader + _define_reader_if_needed(:block, block_reader: true) + end + + def _define_reader_if_needed(key, block_reader: false) + if method_defined?(key, false) + # Method defined on THIS class (not inherited) + if _reader_defined_by_us?(key) + # We defined it (e.g. reload) - silently skip + return + else + # User defined it on this class - warn and skip + _warn_method_exists(key, block_reader: block_reader) + return + end + elsif method_defined?(key) + # Method inherited - check if it's our reader or a user method + if _ancestor_with_reader(key) + # Ancestor's initialize_with defined it - skip silently (same behavior) + return else - attr_reader key + # User-defined method on ancestor - warn and skip + _warn_method_exists(key, block_reader: block_reader, defined_in: _method_owner_name(key)) + return end end + + # Method doesn't exist - define it and track + _declarative_initialization_readers.add(key) + attr_reader key end - def _set_up_block_reader - if method_defined?(:block) - _logger.warn "[#{_class_name}] Method #block already exists -- may NOT be able to reference a block " \ - "passed to #new as #block (use @block instead)" + def _warn_method_exists(key, block_reader: false, defined_in: nil) + location = defined_in ? "in #{defined_in}" : "on this class" + if block_reader + _logger.warn "[#{_class_name}] Method ##{key} already exists #{location} -- may NOT be able to reference " \ + "a block passed to #new as ##{key} (use @#{key} instead)" else - attr_reader :block + _logger.warn "[#{_class_name}] Method ##{key} already exists #{location} -- skipping attr_reader generation " \ + "(use @#{key} in post-initialize block if you need the value passed to #new)" end end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index 2ca6122..7aa6428 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -42,27 +42,369 @@ def foo = @foo * 100 it { expect(subject.foo).to eq(100) } end - describe "if method already exists" do - let(:klass) do - Class.new do - def foo = "original" - include DeclarativeInitialization + # ============================================================================= + # EXHAUSTIVE WARNING/NO-WARNING COVERAGE + # ============================================================================= + # + # Matrix of scenarios for attribute readers: + # WARN cases: + # - User method on THIS class before initialize_with + # - User method on ANCESTOR (inherited, not our reader) + # NO-WARN cases: + # - Reload (we defined it on this class) + # - Inherited from ancestor's initialize_with (our reader) + # - User method defined AFTER initialize_with (we define first) + # - No conflicting method + # + # Same matrix applies to :block reader + # ============================================================================= + + describe "warning scenarios" do + let(:logger) { instance_double(Logger) } + + # Helper to build warning message + # location: "on this class" or "in SomeClass" for inherited + def attr_warning(key, location: "on this class") + "[Anonymous Class] Method ##{key} already exists #{location} -- skipping attr_reader generation " \ + "(use @#{key} in post-initialize block if you need the value passed to #new)" + end + + def block_warning(location: "on this class") + "[Anonymous Class] Method #block already exists #{location} -- may NOT be able to reference " \ + "a block passed to #new as #block (use @block instead)" + end + + def ancestor_location(ancestor_name) + "in #{ancestor_name}" + end + + # ========================================================================= + # WARN CASES + # ========================================================================= + + describe "user method on THIS class before initialize_with" do + let(:klass) do + Class.new do + def foo = "user-defined" + include DeclarativeInitialization + end + end + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "WARNS" do + expect(logger).to receive(:warn).with(attr_warning(:foo)) + klass.initialize_with(:foo) + end + + it "skips attr_reader, @foo set but foo returns user's value" do + allow(logger).to receive(:warn) + klass.initialize_with(:foo) + instance = klass.new(foo: 123) + expect(instance.foo).to eq("user-defined") + expect(instance.instance_variable_get("@foo")).to eq(123) end end - let(:logger) { instance_double(Logger) } + describe "user method on ANCESTOR (inherited)" do + let(:parent_klass) do + Class.new do + def foo = "parent custom" + end + end + + let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "WARNS with ancestor class name (anonymous)" do + expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + klass.initialize_with(:foo) + end + + it "skips attr_reader, uses inherited method" do + allow(logger).to receive(:warn) + klass.initialize_with(:foo) + instance = klass.new(foo: 123) + expect(instance.foo).to eq("parent custom") + expect(instance.instance_variable_get("@foo")).to eq(123) + end + end + + describe ":block reader with user #block on THIS class" do + let(:klass) do + Class.new do + def block = "user block" + include DeclarativeInitialization + end + end + + before { allow(klass).to receive(:_logger).and_return(logger) } - before do - allow(klass).to receive(:_logger).and_return(logger) - expect(logger).to receive(:warn).with( - "[Anonymous Class] Method #foo already exists -- skipping attr_reader generation" - ) - klass.initialize_with(:foo) + it "WARNS with block-specific message" do + expect(logger).to receive(:warn).with(block_warning) + klass.initialize_with(:foo) + end + + it "uses user's #block, @block still set" do + allow(logger).to receive(:warn) + klass.initialize_with(:foo) + my_block = proc { "test" } + instance = klass.new(foo: 1, &my_block) + expect(instance.block).to eq("user block") + expect(instance.instance_variable_get("@block")).to eq(my_block) + end end - it "does not create attr_reader " do - expect(subject.foo).to eq("original") - expect(subject.instance_variable_get("@foo")).to eq(1) + describe ":block reader with user #block on ANCESTOR" do + let(:parent_klass) do + Class.new do + def block = "parent block" + end + end + + let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "WARNS with ancestor class name (anonymous)" do + expect(logger).to receive(:warn).with(block_warning(location: "in an anonymous ancestor")) + klass.initialize_with(:foo) + end + + it "uses inherited #block method" do + allow(logger).to receive(:warn) + klass.initialize_with(:foo) + my_block = proc { "test" } + instance = klass.new(foo: 1, &my_block) + expect(instance.block).to eq("parent block") + expect(instance.instance_variable_get("@block")).to eq(my_block) + end + end + + describe "multiple attributes, some conflict" do + let(:klass) do + Class.new do + def bar = "user bar" + include DeclarativeInitialization + end + end + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "WARNS only for conflicting attribute" do + expect(logger).to receive(:warn).with(attr_warning(:bar)).once + klass.initialize_with(:foo, :bar, baz: "default") + end + + it "defines readers for non-conflicting, skips conflicting" do + allow(logger).to receive(:warn) + klass.initialize_with(:foo, :bar, baz: "default") + instance = klass.new(foo: 1, bar: 2, baz: 3) + expect(instance.foo).to eq(1) + expect(instance.bar).to eq("user bar") + expect(instance.instance_variable_get("@bar")).to eq(2) + expect(instance.baz).to eq(3) + end + end + + # ========================================================================= + # NO-WARN CASES + # ========================================================================= + + describe "no conflicting method (normal case)" do + let(:klass) { Class.new { include DeclarativeInitialization } } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "does NOT warn" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo, :bar, baz: "default") + end + + it "defines all readers" do + klass.initialize_with(:foo, :bar, baz: "default") + instance = klass.new(foo: 1, bar: 2) + expect(instance).to have_attributes(foo: 1, bar: 2, baz: "default") + expect(instance).to respond_to(:block) + end + end + + describe "user method defined AFTER initialize_with" do + let(:klass) do + Class.new do + include DeclarativeInitialization + initialize_with :foo + + def foo = "user-override-after" + end + end + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "does NOT warn (we define first)" do + expect(logger).not_to receive(:warn) + end + + it "user's method takes precedence" do + instance = klass.new(foo: 123) + expect(instance.foo).to eq("user-override-after") + expect(instance.instance_variable_get("@foo")).to eq(123) + end + end + + describe "reload: initialize_with called twice" do + let(:klass) { Class.new { include DeclarativeInitialization } } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "does NOT warn on second call" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo) + klass.initialize_with(:foo) + end + + it "still works after reload" do + klass.initialize_with(:foo) + klass.initialize_with(:foo) + expect(klass.new(foo: 42).foo).to eq(42) + end + + it "does NOT warn when adding attributes on reload" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo) + klass.initialize_with(:foo, :bar) + end + + it ":block reader also doesn't warn on reload" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo) + klass.initialize_with(:foo) + my_block = proc { "test" } + expect(klass.new(foo: 1, &my_block).block).to eq(my_block) + end + end + + describe "inherited from ancestor's initialize_with" do + let(:parent_klass) do + Class.new do + include DeclarativeInitialization + initialize_with :foo + end + end + + let(:klass) { Class.new(parent_klass) } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "does NOT warn when re-declaring parent's attribute" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo, :bar) + end + + it "child uses parent's reader" do + klass.initialize_with(:foo, :bar) + expect(klass.instance_method(:foo).owner).to eq(parent_klass) + end + + it "works correctly" do + klass.initialize_with(:foo, :bar) + expect(klass.new(foo: 1, bar: 2)).to have_attributes(foo: 1, bar: 2) + end + end + + describe "grandchild with ancestor chain using initialize_with" do + let(:grandparent_klass) do + Class.new do + include DeclarativeInitialization + initialize_with :foo + end + end + + let(:parent_klass) do + Class.new(grandparent_klass) do + initialize_with :foo, :bar + end + end + + let(:klass) { Class.new(parent_klass) } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "does NOT warn when re-declaring ancestors' attributes" do + expect(logger).not_to receive(:warn) + klass.initialize_with(:foo, :bar, :baz) + end + + it "works correctly with all attributes" do + klass.initialize_with(:foo, :bar, :baz) + expect(klass.new(foo: 1, bar: 2, baz: 3)).to have_attributes(foo: 1, bar: 2, baz: 3) + end + end + + # ========================================================================= + # MIXED CASES + # ========================================================================= + + describe "user method on named ancestor class" do + before do + stub_const("NamedParent", Class.new do + def foo = "named parent method" + end) + end + + let(:klass) { Class.new(NamedParent) { include DeclarativeInitialization } } + + before { allow(klass).to receive(:_logger).and_return(logger) } + + it "WARNS with actual class name" do + expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in NamedParent")) + klass.initialize_with(:foo) + end + end + + describe "grandparent user method, parent/child use initialize_with" do + let(:grandparent_klass) do + Class.new do + def foo = "grandparent custom" + end + end + + let(:parent_klass) do + Class.new(grandparent_klass) do + include DeclarativeInitialization + end + end + + let(:klass) { Class.new(parent_klass) } + let(:parent_logger) { instance_double(Logger) } + + before do + allow(parent_klass).to receive(:_logger).and_return(parent_logger) + allow(parent_logger).to receive(:warn) + allow(klass).to receive(:_logger).and_return(logger) + end + + it "parent WARNS with grandparent class name (anonymous)" do + expect(parent_logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + parent_klass.initialize_with(:foo) + end + + it "child also WARNS with grandparent class name (anonymous)" do + parent_klass.initialize_with(:foo) + expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + klass.initialize_with(:foo, :bar) + end + + it "grandparent's method is used throughout" do + parent_klass.initialize_with(:foo) + allow(logger).to receive(:warn) + klass.initialize_with(:foo, :bar) + instance = klass.new(foo: 1, bar: 2) + expect(instance.foo).to eq("grandparent custom") + expect(instance.instance_variable_get("@foo")).to eq(1) + expect(instance.bar).to eq(2) + end end end end From c68b6ed22ee8e7d3519d4b3f9aa11a039fc8ba69 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Wed, 18 Feb 2026 21:05:17 -0800 Subject: [PATCH 03/14] Improve code --- CHANGELOG.md | 7 ++ lib/declarative_initialization.rb | 16 +--- .../class_methods.rb | 92 +++++++++---------- .../instance_methods.rb | 18 +++- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f12a44..bae1e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased] +- Improved "method already exists" warnings: + - No longer warns on Rails reload when the method was defined by us + - No longer warns when subclass re-declares an attribute from parent's `initialize_with` + - Now warns when an inherited user-defined method conflicts (previously skipped silently) + - Warning messages now include the class name where the conflicting method is defined + - Warning messages suggest using `@attr` as a workaround + ## [0.1.1] - 2025-05-02 - Refactor internals - [BUGFIX] Only trigger `attr_reader` creation on initial class load (vs on every call to `#new`) diff --git a/lib/declarative_initialization.rb b/lib/declarative_initialization.rb index f838ae0..1bb12c9 100644 --- a/lib/declarative_initialization.rb +++ b/lib/declarative_initialization.rb @@ -6,18 +6,10 @@ module DeclarativeInitialization def self.included(base) - base.class_eval do - include InstanceMethods - extend ClassMethods - end + base.include InstanceMethods + base.extend ClassMethods end end -# Set up an alias so you can also do `include InitializeWith` -module InitializeWith - def self.included(base) - base.class_eval do - include DeclarativeInitialization - end - end -end +# Alias so you can also do `include InitializeWith` +InitializeWith = DeclarativeInitialization diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index a836a35..f40043a 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -7,12 +7,11 @@ module DeclarativeInitialization module ClassMethods # Defines an initializer expecting the specified keyword arguments. # @param args [Array] Required keyword arguments - # @param kwargs [Hash] Optional keyword arguments (required, but have default values) + # @param kwargs [Hash] Optional keyword arguments with default values # @param post_initialize_block [Proc] Block to execute after initialization (optional) def initialize_with(*args, **kwargs, &post_initialize_block) declared = args + kwargs.keys _validate_arguments!(declared) - _set_up_attribute_readers(declared) _set_up_block_reader _define_initializer(declared, kwargs, post_initialize_block) @@ -24,20 +23,22 @@ def _class_name name || "Anonymous Class" end + def _prefixed(message) + "[#{_class_name}] #{message}" + end + def _logger @_logger ||= if defined?(Rails) && Rails.respond_to?(:logger) Rails.logger else - logger = Logger.new($stdout) - logger.level = Logger::WARN - logger + Logger.new($stdout).tap { |l| l.level = Logger::WARN } end end def _validate_arguments!(declared) return if declared.all? { |arg| arg.is_a?(Symbol) } - raise ArgumentError, "[#{_class_name}] All arguments to #initialize_with must be symbols" + raise ArgumentError, _prefixed("All arguments to #initialize_with must be symbols") end def _declarative_initialization_readers @@ -55,19 +56,13 @@ def _ancestor_with_reader(key) end end - def _ancestor_name(ancestor) - ancestor.name || "an anonymous ancestor" - end - def _method_owner_name(key) owner = instance_method(key).owner owner.name || "an anonymous ancestor" end def _set_up_attribute_readers(declared) - declared.each do |key| - _define_reader_if_needed(key) - end + declared.each { |key| _define_reader_if_needed(key) } end def _set_up_block_reader @@ -75,52 +70,53 @@ def _set_up_block_reader end def _define_reader_if_needed(key, block_reader: false) - if method_defined?(key, false) - # Method defined on THIS class (not inherited) - if _reader_defined_by_us?(key) - # We defined it (e.g. reload) - silently skip - return - else - # User defined it on this class - warn and skip - _warn_method_exists(key, block_reader: block_reader) - return - end - elsif method_defined?(key) - # Method inherited - check if it's our reader or a user method - if _ancestor_with_reader(key) - # Ancestor's initialize_with defined it - skip silently (same behavior) - return - else - # User-defined method on ancestor - warn and skip - _warn_method_exists(key, block_reader: block_reader, defined_in: _method_owner_name(key)) - return - end - end + return if _skip_existing_on_this_class?(key, block_reader: block_reader) + return if _skip_inherited?(key, block_reader: block_reader) - # Method doesn't exist - define it and track _declarative_initialization_readers.add(key) attr_reader key end - def _warn_method_exists(key, block_reader: false, defined_in: nil) - location = defined_in ? "in #{defined_in}" : "on this class" - if block_reader - _logger.warn "[#{_class_name}] Method ##{key} already exists #{location} -- may NOT be able to reference " \ - "a block passed to #new as ##{key} (use @#{key} instead)" - else - _logger.warn "[#{_class_name}] Method ##{key} already exists #{location} -- skipping attr_reader generation " \ - "(use @#{key} in post-initialize block if you need the value passed to #new)" + def _skip_existing_on_this_class?(key, block_reader:) + return false unless method_defined?(key, false) + + unless _reader_defined_by_us?(key) + _warn_method_exists(key, block_reader: block_reader) + end + true + end + + def _skip_inherited?(key, block_reader:) + return false unless method_defined?(key) + + unless _ancestor_with_reader(key) + _warn_method_exists(key, block_reader: block_reader, defined_in: _method_owner_name(key)) end + true + end + + def _warn_method_exists(key, block_reader:, defined_in: nil) + location = defined_in ? "in #{defined_in}" : "on this class" + message = block_reader ? _block_warning(key, location) : _attr_warning(key, location) + _logger.warn _prefixed(message) + end + + def _attr_warning(key, location) + "Method ##{key} already exists #{location} -- skipping attr_reader generation " \ + "(use @#{key} in post-initialize block if you need the value passed to #new)" + end + + def _block_warning(key, location) + "Method ##{key} already exists #{location} -- may NOT be able to reference " \ + "a block passed to #new as ##{key} (use @#{key} instead)" end def _define_initializer(declared, defaults, post_initialize_block) define_method(:initialize) do |*given_args, **given_kwargs, &given_block| - class_name = self.class.name || "Anonymous Class" - _validate_initialization_arguments!(class_name, given_args, given_kwargs, declared, defaults) + _validate_initialization_arguments!(given_args, given_kwargs, declared, defaults) - declared.each do |key| - instance_variable_set(:"@#{key}", given_kwargs.fetch(key, defaults[key])) - end + merged = defaults.merge(given_kwargs) + declared.each { |key| instance_variable_set(:"@#{key}", merged[key]) } instance_variable_set(:@block, given_block) if given_block instance_exec(&post_initialize_block) if post_initialize_block diff --git a/lib/declarative_initialization/instance_methods.rb b/lib/declarative_initialization/instance_methods.rb index 8acce38..2912854 100644 --- a/lib/declarative_initialization/instance_methods.rb +++ b/lib/declarative_initialization/instance_methods.rb @@ -4,14 +4,22 @@ module DeclarativeInitialization module InstanceMethods private - def _validate_initialization_arguments!(class_name, given_args, given_kwargs, declared, defaults) - raise ArgumentError, "[#{class_name}] Only keyword arguments are accepted" unless given_args.empty? + def _class_name + self.class.name || "Anonymous Class" + end + + def _prefixed(message) + "[#{_class_name}] #{message}" + end + + def _validate_initialization_arguments!(given_args, given_kwargs, declared, defaults) + raise ArgumentError, _prefixed("Only keyword arguments are accepted") unless given_args.empty? missing = declared - given_kwargs.keys - defaults.keys - extra = given_kwargs.keys - declared + raise ArgumentError, _prefixed("Missing keyword argument(s): #{missing.join(", ")}") unless missing.empty? - raise ArgumentError, "[#{class_name}] Missing keyword argument(s): #{missing.join(", ")}" unless missing.empty? - raise ArgumentError, "[#{class_name}] Unknown keyword argument(s): #{extra.join(", ")}" unless extra.empty? + extra = given_kwargs.keys - declared + raise ArgumentError, _prefixed("Unknown keyword argument(s): #{extra.join(", ")}") unless extra.empty? end end end From f7e6dc80a43dad84bdd9e47896df7ebd35879138 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Wed, 18 Feb 2026 21:09:44 -0800 Subject: [PATCH 04/14] Reduce injected helper methods --- lib/declarative_initialization.rb | 3 +- .../class_methods.rb | 53 ++------------- .../instance_methods.rb | 25 -------- lib/declarative_initialization/internal.rb | 64 +++++++++++++++++++ spec/reader_spec.rb | 42 ++---------- 5 files changed, 77 insertions(+), 110 deletions(-) delete mode 100644 lib/declarative_initialization/instance_methods.rb create mode 100644 lib/declarative_initialization/internal.rb diff --git a/lib/declarative_initialization.rb b/lib/declarative_initialization.rb index 1bb12c9..c33878d 100644 --- a/lib/declarative_initialization.rb +++ b/lib/declarative_initialization.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true require_relative "declarative_initialization/version" +require_relative "declarative_initialization/internal" require_relative "declarative_initialization/class_methods" -require_relative "declarative_initialization/instance_methods" module DeclarativeInitialization def self.included(base) - base.include InstanceMethods base.extend ClassMethods end end diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index f40043a..66f4cb5 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "logger" require "set" module DeclarativeInitialization @@ -11,7 +10,7 @@ module ClassMethods # @param post_initialize_block [Proc] Block to execute after initialization (optional) def initialize_with(*args, **kwargs, &post_initialize_block) declared = args + kwargs.keys - _validate_arguments!(declared) + Internal.validate_arguments!(self, declared) _set_up_attribute_readers(declared) _set_up_block_reader _define_initializer(declared, kwargs, post_initialize_block) @@ -19,28 +18,6 @@ def initialize_with(*args, **kwargs, &post_initialize_block) private - def _class_name - name || "Anonymous Class" - end - - def _prefixed(message) - "[#{_class_name}] #{message}" - end - - def _logger - @_logger ||= if defined?(Rails) && Rails.respond_to?(:logger) - Rails.logger - else - Logger.new($stdout).tap { |l| l.level = Logger::WARN } - end - end - - def _validate_arguments!(declared) - return if declared.all? { |arg| arg.is_a?(Symbol) } - - raise ArgumentError, _prefixed("All arguments to #initialize_with must be symbols") - end - def _declarative_initialization_readers @_declarative_initialization_readers ||= Set.new end @@ -56,11 +33,6 @@ def _ancestor_with_reader(key) end end - def _method_owner_name(key) - owner = instance_method(key).owner - owner.name || "an anonymous ancestor" - end - def _set_up_attribute_readers(declared) declared.each { |key| _define_reader_if_needed(key) } end @@ -81,7 +53,7 @@ def _skip_existing_on_this_class?(key, block_reader:) return false unless method_defined?(key, false) unless _reader_defined_by_us?(key) - _warn_method_exists(key, block_reader: block_reader) + Internal.warn_method_exists(self, key, block_reader: block_reader) end true end @@ -90,30 +62,15 @@ def _skip_inherited?(key, block_reader:) return false unless method_defined?(key) unless _ancestor_with_reader(key) - _warn_method_exists(key, block_reader: block_reader, defined_in: _method_owner_name(key)) + defined_in = Internal.method_owner_name(self, key) + Internal.warn_method_exists(self, key, block_reader: block_reader, defined_in: defined_in) end true end - def _warn_method_exists(key, block_reader:, defined_in: nil) - location = defined_in ? "in #{defined_in}" : "on this class" - message = block_reader ? _block_warning(key, location) : _attr_warning(key, location) - _logger.warn _prefixed(message) - end - - def _attr_warning(key, location) - "Method ##{key} already exists #{location} -- skipping attr_reader generation " \ - "(use @#{key} in post-initialize block if you need the value passed to #new)" - end - - def _block_warning(key, location) - "Method ##{key} already exists #{location} -- may NOT be able to reference " \ - "a block passed to #new as ##{key} (use @#{key} instead)" - end - def _define_initializer(declared, defaults, post_initialize_block) define_method(:initialize) do |*given_args, **given_kwargs, &given_block| - _validate_initialization_arguments!(given_args, given_kwargs, declared, defaults) + Internal.validate_initialization_arguments!(self.class, given_args, given_kwargs, declared, defaults) merged = defaults.merge(given_kwargs) declared.each { |key| instance_variable_set(:"@#{key}", merged[key]) } diff --git a/lib/declarative_initialization/instance_methods.rb b/lib/declarative_initialization/instance_methods.rb deleted file mode 100644 index 2912854..0000000 --- a/lib/declarative_initialization/instance_methods.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module DeclarativeInitialization - module InstanceMethods - private - - def _class_name - self.class.name || "Anonymous Class" - end - - def _prefixed(message) - "[#{_class_name}] #{message}" - end - - def _validate_initialization_arguments!(given_args, given_kwargs, declared, defaults) - raise ArgumentError, _prefixed("Only keyword arguments are accepted") unless given_args.empty? - - missing = declared - given_kwargs.keys - defaults.keys - raise ArgumentError, _prefixed("Missing keyword argument(s): #{missing.join(", ")}") unless missing.empty? - - extra = given_kwargs.keys - declared - raise ArgumentError, _prefixed("Unknown keyword argument(s): #{extra.join(", ")}") unless extra.empty? - end - end -end diff --git a/lib/declarative_initialization/internal.rb b/lib/declarative_initialization/internal.rb new file mode 100644 index 0000000..613e7ef --- /dev/null +++ b/lib/declarative_initialization/internal.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "logger" + +module DeclarativeInitialization + # Internal helpers that don't need to be injected into user classes. + # All methods are module functions - stateless and callable as Internal.method_name + module Internal + module_function + + def class_name(klass) + klass.name || "Anonymous Class" + end + + def prefixed(klass, message) + "[#{class_name(klass)}] #{message}" + end + + def logger + @logger ||= if defined?(Rails) && Rails.respond_to?(:logger) + Rails.logger + else + Logger.new($stdout).tap { |l| l.level = Logger::WARN } + end + end + + def validate_arguments!(klass, declared) + return if declared.all? { |arg| arg.is_a?(Symbol) } + + raise ArgumentError, prefixed(klass, "All arguments to #initialize_with must be symbols") + end + + def validate_initialization_arguments!(klass, given_args, given_kwargs, declared, defaults) + raise ArgumentError, prefixed(klass, "Only keyword arguments are accepted") unless given_args.empty? + + missing = declared - given_kwargs.keys - defaults.keys + raise ArgumentError, prefixed(klass, "Missing keyword argument(s): #{missing.join(", ")}") unless missing.empty? + + extra = given_kwargs.keys - declared + raise ArgumentError, prefixed(klass, "Unknown keyword argument(s): #{extra.join(", ")}") unless extra.empty? + end + + def method_owner_name(klass, key) + owner = klass.instance_method(key).owner + owner.name || "an anonymous ancestor" + end + + def warn_method_exists(klass, key, block_reader:, defined_in: nil) + location = defined_in ? "in #{defined_in}" : "on this class" + message = block_reader ? block_warning(key, location) : attr_warning(key, location) + logger.warn prefixed(klass, message) + end + + def attr_warning(key, location) + "Method ##{key} already exists #{location} -- skipping attr_reader generation " \ + "(use @#{key} in post-initialize block if you need the value passed to #new)" + end + + def block_warning(key, location) + "Method ##{key} already exists #{location} -- may NOT be able to reference " \ + "a block passed to #new as ##{key} (use @#{key} instead)" + end + end +end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index 7aa6428..dd57bd7 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -62,6 +62,10 @@ def foo = @foo * 100 describe "warning scenarios" do let(:logger) { instance_double(Logger) } + before do + allow(DeclarativeInitialization::Internal).to receive(:logger).and_return(logger) + end + # Helper to build warning message # location: "on this class" or "in SomeClass" for inherited def attr_warning(key, location: "on this class") @@ -74,10 +78,6 @@ def block_warning(location: "on this class") "a block passed to #new as #block (use @block instead)" end - def ancestor_location(ancestor_name) - "in #{ancestor_name}" - end - # ========================================================================= # WARN CASES # ========================================================================= @@ -90,8 +90,6 @@ def foo = "user-defined" end end - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS" do expect(logger).to receive(:warn).with(attr_warning(:foo)) klass.initialize_with(:foo) @@ -115,8 +113,6 @@ def foo = "parent custom" let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS with ancestor class name (anonymous)" do expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) klass.initialize_with(:foo) @@ -139,8 +135,6 @@ def block = "user block" end end - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS with block-specific message" do expect(logger).to receive(:warn).with(block_warning) klass.initialize_with(:foo) @@ -165,8 +159,6 @@ def block = "parent block" let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS with ancestor class name (anonymous)" do expect(logger).to receive(:warn).with(block_warning(location: "in an anonymous ancestor")) klass.initialize_with(:foo) @@ -190,8 +182,6 @@ def bar = "user bar" end end - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS only for conflicting attribute" do expect(logger).to receive(:warn).with(attr_warning(:bar)).once klass.initialize_with(:foo, :bar, baz: "default") @@ -215,8 +205,6 @@ def bar = "user bar" describe "no conflicting method (normal case)" do let(:klass) { Class.new { include DeclarativeInitialization } } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "does NOT warn" do expect(logger).not_to receive(:warn) klass.initialize_with(:foo, :bar, baz: "default") @@ -240,8 +228,6 @@ def foo = "user-override-after" end end - before { allow(klass).to receive(:_logger).and_return(logger) } - it "does NOT warn (we define first)" do expect(logger).not_to receive(:warn) end @@ -256,8 +242,6 @@ def foo = "user-override-after" describe "reload: initialize_with called twice" do let(:klass) { Class.new { include DeclarativeInitialization } } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "does NOT warn on second call" do expect(logger).not_to receive(:warn) klass.initialize_with(:foo) @@ -295,8 +279,6 @@ def foo = "user-override-after" let(:klass) { Class.new(parent_klass) } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "does NOT warn when re-declaring parent's attribute" do expect(logger).not_to receive(:warn) klass.initialize_with(:foo, :bar) @@ -329,8 +311,6 @@ def foo = "user-override-after" let(:klass) { Class.new(parent_klass) } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "does NOT warn when re-declaring ancestors' attributes" do expect(logger).not_to receive(:warn) klass.initialize_with(:foo, :bar, :baz) @@ -355,8 +335,6 @@ def foo = "named parent method" let(:klass) { Class.new(NamedParent) { include DeclarativeInitialization } } - before { allow(klass).to receive(:_logger).and_return(logger) } - it "WARNS with actual class name" do expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in NamedParent")) klass.initialize_with(:foo) @@ -377,28 +355,22 @@ def foo = "grandparent custom" end let(:klass) { Class.new(parent_klass) } - let(:parent_logger) { instance_double(Logger) } - - before do - allow(parent_klass).to receive(:_logger).and_return(parent_logger) - allow(parent_logger).to receive(:warn) - allow(klass).to receive(:_logger).and_return(logger) - end it "parent WARNS with grandparent class name (anonymous)" do - expect(parent_logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) parent_klass.initialize_with(:foo) end it "child also WARNS with grandparent class name (anonymous)" do + allow(logger).to receive(:warn) parent_klass.initialize_with(:foo) expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) klass.initialize_with(:foo, :bar) end it "grandparent's method is used throughout" do - parent_klass.initialize_with(:foo) allow(logger).to receive(:warn) + parent_klass.initialize_with(:foo) klass.initialize_with(:foo, :bar) instance = klass.new(foo: 1, bar: 2) expect(instance.foo).to eq("grandparent custom") From 74c0c502c14418780c739b2b61000d59345c3ab7 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 14:06:37 -0800 Subject: [PATCH 05/14] Override by default --- CHANGELOG.md | 13 +-- README.md | 19 ++-- .../class_methods.rb | 41 ++------ lib/declarative_initialization/internal.rb | 46 ++++----- spec/reader_spec.rb | 94 +++++++++---------- 5 files changed, 96 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bae1e83..ff06abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ ## [Unreleased] -- Improved "method already exists" warnings: - - No longer warns on Rails reload when the method was defined by us - - No longer warns when subclass re-declares an attribute from parent's `initialize_with` - - Now warns when an inherited user-defined method conflicts (previously skipped silently) - - Warning messages now include the class name where the conflicting method is defined - - Warning messages suggest using `@attr` as a workaround +- **BREAKING:** Override by default when a method already exists + - Previously, if a method `#foo` existed (same class or ancestor), we skipped defining the reader and warned. Users had to use `@foo` to access the init-arg. + - Now, we **always define the reader**, overriding any existing method so `foo` consistently returns the init-arg value. + - If you rely on an existing method (e.g. ViewComponent's `renders_one :title`), use a different init-arg name (e.g. `title_content: nil`). +- Optional override warning in development/test (Rails) or when logger level is DEBUG +- No longer warns on Rails reload when the method was defined by us +- No longer warns when subclass re-declares an attribute from parent's `initialize_with` ## [0.1.1] - 2025-05-02 - Refactor internals diff --git a/README.md b/README.md index 58e5a62..ce42419 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,24 @@ We support that by passing an optional block to `initialize_with` -- for instanc * Accepting a block: this is handled automatically -- if a block was provided to the Foo.new call, it'll be made available as `@block`/`attr_reader :block` -* If a method with the same name already exists, we log a warning and do not create the `attr_reader`. In that case you'll need to reference the instance variable directly (e.g. `@foo` instead of `foo`). +* **Method conflicts (override by default):** If a method with the same name already exists, we **override** it with our `attr_reader` so that `foo` consistently returns the init-arg value: - * **On this class:** If you define `def foo` before calling `initialize_with :foo`, we warn and skip. + * **On this class:** If you define `def foo` before calling `initialize_with :foo`, we override your method. Our reader wins. - * **Inherited from an ancestor:** If a parent class defines `def foo`, we also warn and skip. This catches the case where a subclass expects `foo` to return the init value but an ancestor has a different implementation. + * **Inherited from an ancestor:** If a parent class defines `def foo`, we also override it. This ensures the init-arg is always accessible via `foo`. - * **Exception:** If the inherited method was created by an ancestor's `initialize_with` (i.e. our `attr_reader`), we skip silently—no warning, since the behavior is the same. + * **Exception:** If the inherited method was created by an ancestor's `initialize_with` (i.e. our `attr_reader`), we skip silently—no redefinition, since the behavior is the same. - * **Caution:** If the existing method doesn't read the instance variable, `@foo` is still set but calling `foo` returns something else, which can cause subtle bugs. + * **Optional warning:** In development/test environments (Rails) or when the logger level is DEBUG, we log a warning when overriding an existing method, so you're aware of the conflict. - * Because of this, **best practice when referencing variables in the post-initialize block is to use `@foo` rather than relying on the `foo` attr_reader** + * **If you need the existing method:** Use a different name for your init-arg. For example, if you use ViewComponent's `renders_one :title`, don't also use `initialize_with title: nil`—instead use `initialize_with title_content: nil`. + +* **User method defined after initialize_with:** If you define `def foo` _after_ calling `initialize_with :foo`, your method takes precedence (Ruby's last-definition-wins behavior). This is useful for custom transformations: + + ```ruby + initialize_with :key + def key = @key.to_sym + ``` * Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration: diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index 66f4cb5..50a43f9 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -11,8 +11,8 @@ module ClassMethods def initialize_with(*args, **kwargs, &post_initialize_block) declared = args + kwargs.keys Internal.validate_arguments!(self, declared) - _set_up_attribute_readers(declared) - _set_up_block_reader + declared.each { |key| _define_reader(key) } + _define_reader(:block, block_reader: true) _define_initializer(declared, kwargs, post_initialize_block) end @@ -22,10 +22,6 @@ def _declarative_initialization_readers @_declarative_initialization_readers ||= Set.new end - def _reader_defined_by_us?(key) - _declarative_initialization_readers.include?(key) - end - def _ancestor_with_reader(key) ancestors.drop(1).find do |ancestor| ancestor.instance_variable_defined?(:@_declarative_initialization_readers) && @@ -33,41 +29,16 @@ def _ancestor_with_reader(key) end end - def _set_up_attribute_readers(declared) - declared.each { |key| _define_reader_if_needed(key) } - end + def _define_reader(key, block_reader: false) + return if _declarative_initialization_readers.include?(key) + return if _ancestor_with_reader(key) - def _set_up_block_reader - _define_reader_if_needed(:block, block_reader: true) - end - - def _define_reader_if_needed(key, block_reader: false) - return if _skip_existing_on_this_class?(key, block_reader: block_reader) - return if _skip_inherited?(key, block_reader: block_reader) + Internal.warn_override(self, key, block_reader: block_reader) if method_defined?(key) _declarative_initialization_readers.add(key) attr_reader key end - def _skip_existing_on_this_class?(key, block_reader:) - return false unless method_defined?(key, false) - - unless _reader_defined_by_us?(key) - Internal.warn_method_exists(self, key, block_reader: block_reader) - end - true - end - - def _skip_inherited?(key, block_reader:) - return false unless method_defined?(key) - - unless _ancestor_with_reader(key) - defined_in = Internal.method_owner_name(self, key) - Internal.warn_method_exists(self, key, block_reader: block_reader, defined_in: defined_in) - end - true - end - def _define_initializer(declared, defaults, post_initialize_block) define_method(:initialize) do |*given_args, **given_kwargs, &given_block| Internal.validate_initialization_arguments!(self.class, given_args, given_kwargs, declared, defaults) diff --git a/lib/declarative_initialization/internal.rb b/lib/declarative_initialization/internal.rb index 613e7ef..866e266 100644 --- a/lib/declarative_initialization/internal.rb +++ b/lib/declarative_initialization/internal.rb @@ -8,14 +8,6 @@ module DeclarativeInitialization module Internal module_function - def class_name(klass) - klass.name || "Anonymous Class" - end - - def prefixed(klass, message) - "[#{class_name(klass)}] #{message}" - end - def logger @logger ||= if defined?(Rails) && Rails.respond_to?(:logger) Rails.logger @@ -24,6 +16,14 @@ def logger end end + def class_name(klass) + klass.name || "Anonymous Class" + end + + def prefixed(klass, message) + "[#{class_name(klass)}] #{message}" + end + def validate_arguments!(klass, declared) return if declared.all? { |arg| arg.is_a?(Symbol) } @@ -40,25 +40,27 @@ def validate_initialization_arguments!(klass, given_args, given_kwargs, declared raise ArgumentError, prefixed(klass, "Unknown keyword argument(s): #{extra.join(", ")}") unless extra.empty? end - def method_owner_name(klass, key) - owner = klass.instance_method(key).owner - owner.name || "an anonymous ancestor" - end + def warn_override(klass, key, block_reader:) + return unless should_warn_override? - def warn_method_exists(klass, key, block_reader:, defined_in: nil) - location = defined_in ? "in #{defined_in}" : "on this class" - message = block_reader ? block_warning(key, location) : attr_warning(key, location) - logger.warn prefixed(klass, message) + location = override_location(klass, key) + reader_type = block_reader ? "block" : "init-arg" + logger.warn prefixed(klass, "Method ##{key} already exists #{location} -- overriding with #{reader_type} reader") end - def attr_warning(key, location) - "Method ##{key} already exists #{location} -- skipping attr_reader generation " \ - "(use @#{key} in post-initialize block if you need the value passed to #new)" + def should_warn_override? + return true if defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?) + + logger.level <= Logger::DEBUG end - def block_warning(key, location) - "Method ##{key} already exists #{location} -- may NOT be able to reference " \ - "a block passed to #new as ##{key} (use @#{key} instead)" + def override_location(klass, key) + if klass.method_defined?(key, false) + "on this class" + else + owner = klass.instance_method(key).owner + "in #{owner.name || "an anonymous ancestor"}" + end end end end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index dd57bd7..0991e62 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -43,43 +43,40 @@ def foo = @foo * 100 end # ============================================================================= - # EXHAUSTIVE WARNING/NO-WARNING COVERAGE + # OVERRIDE BEHAVIOR AND OPTIONAL WARNING COVERAGE # ============================================================================= # # Matrix of scenarios for attribute readers: - # WARN cases: + # OVERRIDE + OPTIONAL WARN cases: # - User method on THIS class before initialize_with # - User method on ANCESTOR (inherited, not our reader) - # NO-WARN cases: + # NO-WARN + NO-OVERRIDE cases: # - Reload (we defined it on this class) # - Inherited from ancestor's initialize_with (our reader) - # - User method defined AFTER initialize_with (we define first) + # - User method defined AFTER initialize_with (user's method wins) # - No conflicting method # # Same matrix applies to :block reader # ============================================================================= - describe "warning scenarios" do - let(:logger) { instance_double(Logger) } + describe "override and warning scenarios" do + let(:logger) { instance_double(Logger, level: Logger::DEBUG) } before do allow(DeclarativeInitialization::Internal).to receive(:logger).and_return(logger) + allow(DeclarativeInitialization::Internal).to receive(:should_warn_override?).and_return(true) end - # Helper to build warning message - # location: "on this class" or "in SomeClass" for inherited - def attr_warning(key, location: "on this class") - "[Anonymous Class] Method ##{key} already exists #{location} -- skipping attr_reader generation " \ - "(use @#{key} in post-initialize block if you need the value passed to #new)" + def attr_override_warning(key, location: "on this class") + "[Anonymous Class] Method ##{key} already exists #{location} -- overriding with init-arg reader" end - def block_warning(location: "on this class") - "[Anonymous Class] Method #block already exists #{location} -- may NOT be able to reference " \ - "a block passed to #new as #block (use @block instead)" + def block_override_warning(location: "on this class") + "[Anonymous Class] Method #block already exists #{location} -- overriding with block reader" end # ========================================================================= - # WARN CASES + # OVERRIDE + OPTIONAL WARN CASES # ========================================================================= describe "user method on THIS class before initialize_with" do @@ -90,17 +87,16 @@ def foo = "user-defined" end end - it "WARNS" do - expect(logger).to receive(:warn).with(attr_warning(:foo)) + it "WARNS when overriding" do + expect(logger).to receive(:warn).with(attr_override_warning(:foo)) klass.initialize_with(:foo) end - it "skips attr_reader, @foo set but foo returns user's value" do + it "overrides user method, foo returns init-arg value" do allow(logger).to receive(:warn) klass.initialize_with(:foo) instance = klass.new(foo: 123) - expect(instance.foo).to eq("user-defined") - expect(instance.instance_variable_get("@foo")).to eq(123) + expect(instance.foo).to eq(123) end end @@ -113,17 +109,16 @@ def foo = "parent custom" let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } - it "WARNS with ancestor class name (anonymous)" do - expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + it "WARNS when overriding with ancestor class name (anonymous)" do + expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in an anonymous ancestor")) klass.initialize_with(:foo) end - it "skips attr_reader, uses inherited method" do + it "overrides inherited method, foo returns init-arg value" do allow(logger).to receive(:warn) klass.initialize_with(:foo) instance = klass.new(foo: 123) - expect(instance.foo).to eq("parent custom") - expect(instance.instance_variable_get("@foo")).to eq(123) + expect(instance.foo).to eq(123) end end @@ -135,18 +130,17 @@ def block = "user block" end end - it "WARNS with block-specific message" do - expect(logger).to receive(:warn).with(block_warning) + it "WARNS when overriding with block-specific message" do + expect(logger).to receive(:warn).with(block_override_warning) klass.initialize_with(:foo) end - it "uses user's #block, @block still set" do + it "overrides user's #block, returns block passed to new" do allow(logger).to receive(:warn) klass.initialize_with(:foo) my_block = proc { "test" } instance = klass.new(foo: 1, &my_block) - expect(instance.block).to eq("user block") - expect(instance.instance_variable_get("@block")).to eq(my_block) + expect(instance.block).to eq(my_block) end end @@ -159,18 +153,17 @@ def block = "parent block" let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } } - it "WARNS with ancestor class name (anonymous)" do - expect(logger).to receive(:warn).with(block_warning(location: "in an anonymous ancestor")) + it "WARNS when overriding with ancestor class name (anonymous)" do + expect(logger).to receive(:warn).with(block_override_warning(location: "in an anonymous ancestor")) klass.initialize_with(:foo) end - it "uses inherited #block method" do + it "overrides inherited #block, returns block passed to new" do allow(logger).to receive(:warn) klass.initialize_with(:foo) my_block = proc { "test" } instance = klass.new(foo: 1, &my_block) - expect(instance.block).to eq("parent block") - expect(instance.instance_variable_get("@block")).to eq(my_block) + expect(instance.block).to eq(my_block) end end @@ -183,17 +176,16 @@ def bar = "user bar" end it "WARNS only for conflicting attribute" do - expect(logger).to receive(:warn).with(attr_warning(:bar)).once + expect(logger).to receive(:warn).with(attr_override_warning(:bar)).once klass.initialize_with(:foo, :bar, baz: "default") end - it "defines readers for non-conflicting, skips conflicting" do + it "overrides conflicting, all attributes return init-arg values" do allow(logger).to receive(:warn) klass.initialize_with(:foo, :bar, baz: "default") instance = klass.new(foo: 1, bar: 2, baz: 3) expect(instance.foo).to eq(1) - expect(instance.bar).to eq("user bar") - expect(instance.instance_variable_get("@bar")).to eq(2) + expect(instance.bar).to eq(2) expect(instance.baz).to eq(3) end end @@ -335,9 +327,16 @@ def foo = "named parent method" let(:klass) { Class.new(NamedParent) { include DeclarativeInitialization } } - it "WARNS with actual class name" do - expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in NamedParent")) + it "WARNS when overriding with actual class name" do + expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in NamedParent")) + klass.initialize_with(:foo) + end + + it "overrides ancestor method, foo returns init-arg value" do + allow(logger).to receive(:warn) klass.initialize_with(:foo) + instance = klass.new(foo: 123) + expect(instance.foo).to eq(123) end end @@ -356,25 +355,24 @@ def foo = "grandparent custom" let(:klass) { Class.new(parent_klass) } - it "parent WARNS with grandparent class name (anonymous)" do - expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + it "parent WARNS when overriding with grandparent class name (anonymous)" do + expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in an anonymous ancestor")) parent_klass.initialize_with(:foo) end - it "child also WARNS with grandparent class name (anonymous)" do + it "child does NOT warn because parent already has our reader" do allow(logger).to receive(:warn) parent_klass.initialize_with(:foo) - expect(logger).to receive(:warn).with(attr_warning(:foo, location: "in an anonymous ancestor")) + expect(logger).not_to receive(:warn) klass.initialize_with(:foo, :bar) end - it "grandparent's method is used throughout" do + it "parent's reader is used, init-arg value returned" do allow(logger).to receive(:warn) parent_klass.initialize_with(:foo) klass.initialize_with(:foo, :bar) instance = klass.new(foo: 1, bar: 2) - expect(instance.foo).to eq("grandparent custom") - expect(instance.instance_variable_get("@foo")).to eq(1) + expect(instance.foo).to eq(1) expect(instance.bar).to eq(2) end end From c36ba4e45d45b8f1f02add78fca7970501c0d7f1 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 15:19:32 -0800 Subject: [PATCH 06/14] terser code --- .rubocop.yml | 1 + .../class_methods.rb | 30 +++++++-------- lib/declarative_initialization/internal.rb | 37 ++++++++++--------- spec/reader_spec.rb | 2 +- 4 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 9997774..d95a718 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,6 +22,7 @@ Metrics/BlockLength: Enabled: false Metrics/MethodLength: + Max: 15 AllowedMethods: [initialize, initialize_with] Metrics/AbcSize: diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index 50a43f9..3fea064 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -7,47 +7,45 @@ module ClassMethods # Defines an initializer expecting the specified keyword arguments. # @param args [Array] Required keyword arguments # @param kwargs [Hash] Optional keyword arguments with default values - # @param post_initialize_block [Proc] Block to execute after initialization (optional) - def initialize_with(*args, **kwargs, &post_initialize_block) + # @param post_initialize [Proc] Block to execute after initialization (optional) + def initialize_with(*args, **kwargs, &post_initialize) declared = args + kwargs.keys Internal.validate_arguments!(self, declared) declared.each { |key| _define_reader(key) } _define_reader(:block, block_reader: true) - _define_initializer(declared, kwargs, post_initialize_block) + _define_generated_initializer(declared, kwargs, post_initialize) end private - def _declarative_initialization_readers - @_declarative_initialization_readers ||= Set.new + def _declared_readers + @_declared_readers ||= Set.new end - def _ancestor_with_reader(key) - ancestors.drop(1).find do |ancestor| - ancestor.instance_variable_defined?(:@_declarative_initialization_readers) && - ancestor.instance_variable_get(:@_declarative_initialization_readers).include?(key) + def _ancestor_declared_reader?(key) + ancestors.drop(1).any? do |ancestor| + ancestor.instance_variable_get(:@_declared_readers)&.include?(key) end end def _define_reader(key, block_reader: false) - return if _declarative_initialization_readers.include?(key) - return if _ancestor_with_reader(key) + return if _declared_readers.include?(key) + return if _ancestor_declared_reader?(key) Internal.warn_override(self, key, block_reader: block_reader) if method_defined?(key) - _declarative_initialization_readers.add(key) + _declared_readers.add(key) attr_reader key end - def _define_initializer(declared, defaults, post_initialize_block) + def _define_generated_initializer(declared, defaults, post_initialize) define_method(:initialize) do |*given_args, **given_kwargs, &given_block| Internal.validate_initialization_arguments!(self.class, given_args, given_kwargs, declared, defaults) - merged = defaults.merge(given_kwargs) - declared.each { |key| instance_variable_set(:"@#{key}", merged[key]) } + defaults.merge(given_kwargs).each { |key, value| instance_variable_set(:"@#{key}", value) } instance_variable_set(:@block, given_block) if given_block - instance_exec(&post_initialize_block) if post_initialize_block + instance_exec(&post_initialize) if post_initialize end end end diff --git a/lib/declarative_initialization/internal.rb b/lib/declarative_initialization/internal.rb index 866e266..51b2a68 100644 --- a/lib/declarative_initialization/internal.rb +++ b/lib/declarative_initialization/internal.rb @@ -16,51 +16,54 @@ def logger end end - def class_name(klass) + def display_name(klass) klass.name || "Anonymous Class" end - def prefixed(klass, message) - "[#{class_name(klass)}] #{message}" + def format_message(klass, message) + "[#{display_name(klass)}] #{message}" end def validate_arguments!(klass, declared) - return if declared.all? { |arg| arg.is_a?(Symbol) } + return if declared.all?(Symbol) - raise ArgumentError, prefixed(klass, "All arguments to #initialize_with must be symbols") + raise ArgumentError, format_message(klass, "All arguments to #initialize_with must be symbols") end def validate_initialization_arguments!(klass, given_args, given_kwargs, declared, defaults) - raise ArgumentError, prefixed(klass, "Only keyword arguments are accepted") unless given_args.empty? + raise ArgumentError, format_message(klass, "Only keyword arguments are accepted") unless given_args.empty? missing = declared - given_kwargs.keys - defaults.keys - raise ArgumentError, prefixed(klass, "Missing keyword argument(s): #{missing.join(", ")}") unless missing.empty? + unless missing.empty? + raise ArgumentError, format_message(klass, "Missing keyword argument(s): #{missing.join(", ")}") + end extra = given_kwargs.keys - declared - raise ArgumentError, prefixed(klass, "Unknown keyword argument(s): #{extra.join(", ")}") unless extra.empty? + return if extra.empty? + + raise ArgumentError, format_message(klass, "Unknown keyword argument(s): #{extra.join(", ")}") end def warn_override(klass, key, block_reader:) - return unless should_warn_override? + return unless warn_override? location = override_location(klass, key) reader_type = block_reader ? "block" : "init-arg" - logger.warn prefixed(klass, "Method ##{key} already exists #{location} -- overriding with #{reader_type} reader") + logger.warn format_message(klass, + "Method ##{key} already exists #{location} -- overriding with #{reader_type} reader") end - def should_warn_override? + def warn_override? return true if defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?) logger.level <= Logger::DEBUG end def override_location(klass, key) - if klass.method_defined?(key, false) - "on this class" - else - owner = klass.instance_method(key).owner - "in #{owner.name || "an anonymous ancestor"}" - end + return "on this class" if klass.method_defined?(key, false) + + owner = klass.instance_method(key).owner + "in #{owner.name || "an anonymous ancestor"}" end end end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index 0991e62..bf648db 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -64,7 +64,7 @@ def foo = @foo * 100 before do allow(DeclarativeInitialization::Internal).to receive(:logger).and_return(logger) - allow(DeclarativeInitialization::Internal).to receive(:should_warn_override?).and_return(true) + allow(DeclarativeInitialization::Internal).to receive(:warn_override?).and_return(true) end def attr_override_warning(key, location: "on this class") From 931667177ff66599c5b74befaddb93da74e503bd Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 15:48:16 -0800 Subject: [PATCH 07/14] Update CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e5a6445..6a5ea08 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -39,7 +39,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at kali@teamshares.com. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at oss@teamshares.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. From 024d003869f70d953ca25c4235ea8dd3d7a85ae3 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 15:48:52 -0800 Subject: [PATCH 08/14] Update README.md --- README.md | 189 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 112 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index ce42419..25980c1 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,156 @@ -# DeclarativeInitialization +# Declarative Initialization -Boilerplate slows down devs and irritates everyone, plus the added cruft makes it harder to scan for the actual logic in a given file. +Stop writing boilerplate `def initialize` methods. Define your class's inputs declaratively and let Ruby do the rest. -This is a small layer to support declarative initialization _specifically for simple keyword-based classes_. +`DeclarativeInitialization` provides a simple, zero-dependency way to define initialization logic for keyword-based classes. It handles assignment, validation, and reader generation so you can focus on your business logic. -## Usage +## What it does + +* **Generates `initialize`**: Automatically assigns keyword arguments to instance variables. +* **Creates `attr_reader`s**: Exposes arguments as private readers (by default). +* **Validates inputs**: Raises helpful errors for missing or unknown keyword arguments. +* **Handles defaults**: Supports optional arguments with default values. +* **Captures blocks**: Automatically captures any block passed to `.new` as `@block`. + +## When to use it -Given a standard ruby class like so: +Perfect for: +* **Service Objects / Command Objects**: Where you pass in dependencies and arguments to execute a single action. +* **Value Objects**: Where you need immutable state initialized once. +* **ViewComponents**: Where you accept a set of parameters to render a UI component. +* **Configuration Objects**: Where you have many optional flags with defaults. + +## Installation + +Add this line to your application's Gemfile: ```ruby -class SomeObject - def initialize(foo:, bar:, baz: "default value") - @foo = foo - @bar = bar - @baz = baz - end +gem 'declarative_initialization' +``` - attr_reader :foo, :bar, :baz -end +And then execute: + +```bash +bundle install ``` -With this library it can be simplified to: +## Usage + +### Basic Usage + +Include the module and use `initialize_with` to define your required arguments. ```ruby -class SomeObject +class UserGreeter include DeclarativeInitialization - initialize_with :foo, :bar, baz: "default value" + # :user is required + initialize_with :user + + def call + "Hello, #{user.name}!" + end end -``` -## Quick note on naming -The gem name is `declarative_initialization` because there's already a very outdated gem claiming the `initialize_with` name. +greeter = UserGreeter.new(user: current_user) +greeter.call # => "Hello, Alice!" -We've set up an alias, however, so you can do either `include DeclarativeInitialization` _or_ `include InitializeWith`. +# Raises ArgumentError: Missing keyword argument(s): user +UserGreeter.new +``` -FWIW in practice at Teamshares we just `include DeclarativeInitialization` in the base class for all our View Components. +### With Defaults -### Custom logic +You can mix required arguments (symbols) and optional arguments (hash). -Sometimes the existing `initialize` method also does other work, for instance setting initial values for additional instance variables that aren't passed in directly. +```ruby +class SearchService + include DeclarativeInitialization -We support that by passing an optional block to `initialize_with` -- for instance, in the example above if the original version also set `@bang = foo * bar`, we could support that by changing the updated version to: + # :query is required + # :limit defaults to 10 + # :sort defaults to :desc + initialize_with :query, limit: 10, sort: :desc - ```ruby - initialize_with :foo, :bar, baz: "default value" do - @bang = @foo * @bar + def perform + results = perform_search(query) + results = results.take(limit) + sort == :desc ? results.reverse : results end - ``` - -### Edge cases +end -* Accepting a block: this is handled automatically -- if a block was provided to the Foo.new call, it'll be made available as `@block`/`attr_reader :block` +SearchService.new(query: "ruby") # limit=10, sort=:desc +SearchService.new(query: "ruby", limit: 50) # limit=50, sort=:desc +``` -* **Method conflicts (override by default):** If a method with the same name already exists, we **override** it with our `attr_reader` so that `foo` consistently returns the init-arg value: +### Custom Logic (Post-Initialize) - * **On this class:** If you define `def foo` before calling `initialize_with :foo`, we override your method. Our reader wins. +If you need to perform logic after assignment (like computing derived values), pass a block to `initialize_with`. - * **Inherited from an ancestor:** If a parent class defines `def foo`, we also override it. This ensures the init-arg is always accessible via `foo`. +```ruby +class Rectangle + include DeclarativeInitialization - * **Exception:** If the inherited method was created by an ancestor's `initialize_with` (i.e. our `attr_reader`), we skip silently—no redefinition, since the behavior is the same. + initialize_with :width, :height do + # This runs after @width and @height are set + @area = @width * @height + + if @width <= 0 || @height <= 0 + raise ArgumentError, "Dimensions must be positive" + end + end - * **Optional warning:** In development/test environments (Rails) or when the logger level is DEBUG, we log a warning when overriding an existing method, so you're aware of the conflict. + attr_reader :area +end +``` - * **If you need the existing method:** Use a different name for your init-arg. For example, if you use ViewComponent's `renders_one :title`, don't also use `initialize_with title: nil`—instead use `initialize_with title_content: nil`. +### Handling Blocks -* **User method defined after initialize_with:** If you define `def foo` _after_ calling `initialize_with :foo`, your method takes precedence (Ruby's last-definition-wins behavior). This is useful for custom transformations: +If a block is passed to `.new`, it is automatically captured as `@block` and exposed via a `block` reader. - ```ruby - initialize_with :key - def key = @key.to_sym - ``` +```ruby +class Wrapper + include DeclarativeInitialization -* Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration: + initialize_with :tag - * Does _not_ work: - ```ruby - initialize_with :user, company: user.employer - ``` - * Workaround: - ```ruby - initialize_with :user, company: nil do - @company ||= @user.employer - end - ``` + def render + "<#{tag}>#{block.call}" + end +end -* If using `initialize_with` on a subclass where the superclass defines `initialize`, we will _not_ automatically call `super`, because if we do we get this `RuntimeError`: - > implicit argument passing of super from method defined by define_method() is not supported. Specify all arguments explicitly. +Wrapper.new(tag: "div") { "Content" }.render +# => "
Content
" +``` -* If you need to call `super` from the block passed into `initialize_with` (unusual edge case, subclass requires different arguments than parent): +## Edge Cases & Gotchas - * Does _not_ work (due to `instance_exec` changing execution context but _not_ the method lookup chain): - ```ruby - initialize_with :foo do - super(bar: 123) - end - ``` - * Workaround _possible_ (but really, probably more understandable to just fall back to manually writing `def initialize`): - ```ruby - initialize_with :foo do - parent_initialize = method(:initialize).super_method - parent_initialize.call(bar: 123) - end - ``` +### Method Conflicts +`DeclarativeInitialization` creates `attr_reader` methods for all arguments. +* **If a method already exists:** It will be **overridden** to ensure the initializer works correctly. +* **Warning:** In development/test environments, it will log a warning if it overrides an existing method (unless that method was defined by an ancestor's `initialize_with`). -* If you find yourself backed into a weird corner, just use a plain ole `def initialize`! This library is meant to make the easy cases less work, but there's no requirement that you must use it for every super complex case you run into. :) +### Referencing Other Defaults +You cannot reference one argument in the default value of another within the declaration itself. -## Development +**❌ Doesn't work:** +```ruby +initialize_with :user, account: user.account # Error: user undefined +``` -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +**✅ Workaround:** +Use the post-initialize block or lazy initialization. +```ruby +initialize_with :user, account: nil do + @account ||= user.account +end +``` -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +### Inheritance and `super` +Because `initialize` is generated dynamically: +1. **Calling `super`**: You cannot easily call `super` from a custom `initialize` if you mix `DeclarativeInitialization` with manual `def initialize`. It's best to stick to `initialize_with` across the hierarchy or use manual initialization for complex inheritance chains. +2. **Overriding**: If a subclass uses `initialize_with`, it completely replaces the parent's `initialize` method. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/declarative_initialization. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md). - -## Code of Conduct - -Everyone interacting in the DeclarativeInitialization project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md). From e94f125a694f005a0884db25ce1d4f87db3f8078 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 15:55:59 -0800 Subject: [PATCH 09/14] Prep for release --- CHANGELOG.md | 7 +++++-- lib/declarative_initialization/version.rb | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff06abc..f55ddcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ ## [Unreleased] -- **BREAKING:** Override by default when a method already exists +* N/A + +## [0.2.0] - 2026-02-19 + +- **BREAKING:** Override when a method already exists - Previously, if a method `#foo` existed (same class or ancestor), we skipped defining the reader and warned. Users had to use `@foo` to access the init-arg. - Now, we **always define the reader**, overriding any existing method so `foo` consistently returns the init-arg value. - - If you rely on an existing method (e.g. ViewComponent's `renders_one :title`), use a different init-arg name (e.g. `title_content: nil`). - Optional override warning in development/test (Rails) or when logger level is DEBUG - No longer warns on Rails reload when the method was defined by us - No longer warns when subclass re-declares an attribute from parent's `initialize_with` diff --git a/lib/declarative_initialization/version.rb b/lib/declarative_initialization/version.rb index 1ac1755..097f610 100644 --- a/lib/declarative_initialization/version.rb +++ b/lib/declarative_initialization/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module DeclarativeInitialization - VERSION = "0.1.1" + VERSION = "0.2.0" end From e230ae32f787be26f17a515a2ab7208dbebbc4ea Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 16:12:35 -0800 Subject: [PATCH 10/14] fix issue with shared defaults --- .../class_methods.rb | 8 +++++- lib/declarative_initialization/internal.rb | 19 ++++++++++++++ spec/reader_spec.rb | 25 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb index 3fea064..02647d8 100644 --- a/lib/declarative_initialization/class_methods.rb +++ b/lib/declarative_initialization/class_methods.rb @@ -42,7 +42,13 @@ def _define_generated_initializer(declared, defaults, post_initialize) define_method(:initialize) do |*given_args, **given_kwargs, &given_block| Internal.validate_initialization_arguments!(self.class, given_args, given_kwargs, declared, defaults) - defaults.merge(given_kwargs).each { |key, value| instance_variable_set(:"@#{key}", value) } + defaults.each do |key, value| + next if given_kwargs.key?(key) + + instance_variable_set(:"@#{key}", Internal.copy_default(value)) + end + + given_kwargs.each { |key, value| instance_variable_set(:"@#{key}", value) } instance_variable_set(:@block, given_block) if given_block instance_exec(&post_initialize) if post_initialize diff --git a/lib/declarative_initialization/internal.rb b/lib/declarative_initialization/internal.rb index 51b2a68..adcb82e 100644 --- a/lib/declarative_initialization/internal.rb +++ b/lib/declarative_initialization/internal.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "logger" +require "set" module DeclarativeInitialization # Internal helpers that don't need to be injected into user classes. @@ -65,5 +66,23 @@ def override_location(klass, key) owner = klass.instance_method(key).owner "in #{owner.name || "an anonymous ancestor"}" end + + # Defensive copy for common mutable default values. + # + # Defaults passed to `initialize_with` are created once at class definition + # time. Without copying, `[]` / `{}` / `Set.new` defaults can be shared across + # instances and accidentally mutated. + # + # This is intentionally shallow, and only for common core mutable types. + def copy_default(value) + return value if value.nil? || value.frozen? + + case value + when Array, Hash, Set, String + value.dup + else + value + end + end end end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index bf648db..519b4cd 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -42,6 +42,31 @@ def foo = @foo * 100 it { expect(subject.foo).to eq(100) } end + describe "defaults" do + let(:klass) do + Class.new do + include DeclarativeInitialization + initialize_with items: [] + end + end + + it "duplicates mutable default values per instance" do + a = klass.new + b = klass.new + + expect(a.items).to eq([]) + expect(b.items).to eq([]) + expect(a.items).not_to be(b.items) + end + + it "does not duplicate caller-provided values" do + provided = [] + instance = klass.new(items: provided) + + expect(instance.items).to be(provided) + end + end + # ============================================================================= # OVERRIDE BEHAVIOR AND OPTIONAL WARNING COVERAGE # ============================================================================= From de63ffcbef890836c4f90eac3b65be753a62f644 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 16:12:47 -0800 Subject: [PATCH 11/14] Update README.md --- README.md | 149 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 25980c1..4879d90 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,50 @@ -# Declarative Initialization +# DeclarativeInitialization -Stop writing boilerplate `def initialize` methods. Define your class's inputs declaratively and let Ruby do the rest. +Declare a class’s keyword inputs once and get a keyword-only `initialize` with assignments, readers, and helpful argument errors. -`DeclarativeInitialization` provides a simple, zero-dependency way to define initialization logic for keyword-based classes. It handles assignment, validation, and reader generation so you can focus on your business logic. +- **Keyword-only initializer**: rejects positional args and unknown keywords +- **Assignments**: sets `@keyword` instance variables from declared inputs +- **Readers**: defines `attr_reader` for each input (and a `block` reader) +- **Defaults**: supports optional keywords with default values +- **No dependencies**: plain Ruby (\(>= 3.0\)) -## What it does +## When to use it -* **Generates `initialize`**: Automatically assigns keyword arguments to instance variables. -* **Creates `attr_reader`s**: Exposes arguments as private readers (by default). -* **Validates inputs**: Raises helpful errors for missing or unknown keyword arguments. -* **Handles defaults**: Supports optional arguments with default values. -* **Captures blocks**: Automatically captures any block passed to `.new` as `@block`. +Use this when you have small POROs that take keyword inputs and you’re tired of repeating the same initializer boilerplate: -## When to use it +- **Service / command objects** that take dependencies and parameters +- **Value objects** with a fixed set of attributes +- **Configuration objects** with a handful of optional flags +- **Components / presenters** that accept a stable set of inputs -Perfect for: -* **Service Objects / Command Objects**: Where you pass in dependencies and arguments to execute a single action. -* **Value Objects**: Where you need immutable state initialized once. -* **ViewComponents**: Where you accept a set of parameters to render a UI component. -* **Configuration Objects**: Where you have many optional flags with defaults. +If you need complex inheritance initialization, multiple initializer “shapes”, or highly dynamic defaults, a handwritten `initialize` may be clearer. ## Installation -Add this line to your application's Gemfile: +Add to your Gemfile: ```ruby -gem 'declarative_initialization' +gem "declarative_initialization" ``` -And then execute: +Then install: ```bash bundle install ``` -## Usage +In non-Bundler contexts, require it directly: -### Basic Usage +```ruby +require "declarative_initialization" +``` -Include the module and use `initialize_with` to define your required arguments. +## Quick start ```ruby class UserGreeter include DeclarativeInitialization - # :user is required initialize_with :user def call @@ -52,61 +52,58 @@ class UserGreeter end end -greeter = UserGreeter.new(user: current_user) -greeter.call # => "Hello, Alice!" +UserGreeter.new(user: current_user).call +# => "Hello, Alice!" -# Raises ArgumentError: Missing keyword argument(s): user UserGreeter.new +# ArgumentError: [UserGreeter] Missing keyword argument(s): user + +UserGreeter.new(user: current_user, extra: true) +# ArgumentError: [UserGreeter] Unknown keyword argument(s): extra ``` -### With Defaults +## Usage -You can mix required arguments (symbols) and optional arguments (hash). +### Required vs optional keywords (defaults) + +Declare required keywords as symbols, and optional keywords as keyword arguments: ```ruby -class SearchService +class Search include DeclarativeInitialization - # :query is required - # :limit defaults to 10 - # :sort defaults to :desc - initialize_with :query, limit: 10, sort: :desc + initialize_with :query, limit: 10, order: :desc - def perform - results = perform_search(query) - results = results.take(limit) - sort == :desc ? results.reverse : results + def call + results = perform_search(query).take(limit) + order == :desc ? results.reverse : results end end -SearchService.new(query: "ruby") # limit=10, sort=:desc -SearchService.new(query: "ruby", limit: 50) # limit=50, sort=:desc +Search.new(query: "ruby").call +Search.new(query: "ruby", limit: 50).call ``` -### Custom Logic (Post-Initialize) +### Post-initialize hook -If you need to perform logic after assignment (like computing derived values), pass a block to `initialize_with`. +Pass a block to `initialize_with` to run code after assignments. The block runs in the instance context. ```ruby class Rectangle include DeclarativeInitialization initialize_with :width, :height do - # This runs after @width and @height are set - @area = @width * @height - - if @width <= 0 || @height <= 0 - raise ArgumentError, "Dimensions must be positive" - end + raise ArgumentError, "Dimensions must be positive" if width <= 0 || height <= 0 + @area = width * height end attr_reader :area end ``` -### Handling Blocks +### Capturing a block passed to `.new` -If a block is passed to `.new`, it is automatically captured as `@block` and exposed via a `block` reader. +If the caller passes a block to `.new`, it’s stored in `@block` and available via the `block` reader. ```ruby class Wrapper @@ -115,31 +112,53 @@ class Wrapper initialize_with :tag def render - "<#{tag}>#{block.call}" + "<#{tag}>#{block&.call}" end end -Wrapper.new(tag: "div") { "Content" }.render +Wrapper.new(tag: "div") { "Content" }.render # => "
Content
" ``` -## Edge Cases & Gotchas +## Behavior notes / gotchas + +### Only keyword arguments are accepted + +The generated initializer is keyword-only. Passing positional arguments raises an `ArgumentError`. + +### Readers are public by default + +Inputs are exposed with `attr_reader`. If you prefer private readers, make them private after the declaration: + +```ruby +class Example + include DeclarativeInitialization + initialize_with :user, admin: false + + private :user, :admin +end +``` + +### Defaults are literal values -### Method Conflicts -`DeclarativeInitialization` creates `attr_reader` methods for all arguments. -* **If a method already exists:** It will be **overridden** to ensure the initializer works correctly. -* **Warning:** In development/test environments, it will log a warning if it overrides an existing method (unless that method was defined by an ancestor's `initialize_with`). +Defaults are applied when the caller omits that keyword. For common mutable defaults (`Array`, `Hash`, `Set`, `String`), the value is duplicated per instance (shallow). If you need deeper setup (or derived values), use the post-initialize block. -### Referencing Other Defaults -You cannot reference one argument in the default value of another within the declaration itself. +### Method name conflicts + +`initialize_with` defines readers for each declared input (and `block`). If a method with the same name already exists, it will be overridden. + +In Rails development/test (or when your logger level allows it), the gem logs a warning when it overrides an existing method. + +### Referencing other inputs in defaults + +You can’t reference one declared input from another input’s default at declaration time: -**❌ Doesn't work:** ```ruby -initialize_with :user, account: user.account # Error: user undefined +initialize_with :user, account: user.account # user is not available here ``` -**✅ Workaround:** -Use the post-initialize block or lazy initialization. +Use the post-initialize block instead: + ```ruby initialize_with :user, account: nil do @account ||= user.account @@ -147,10 +166,10 @@ end ``` ### Inheritance and `super` -Because `initialize` is generated dynamically: -1. **Calling `super`**: You cannot easily call `super` from a custom `initialize` if you mix `DeclarativeInitialization` with manual `def initialize`. It's best to stick to `initialize_with` across the hierarchy or use manual initialization for complex inheritance chains. -2. **Overriding**: If a subclass uses `initialize_with`, it completely replaces the parent's `initialize` method. + +`initialize_with` generates an `initialize` method. If a subclass calls `initialize_with`, it replaces the parent initializer and **does not call `super`**. Prefer a single initializer per hierarchy, or avoid this gem for complex inheritance chains. ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/declarative_initialization. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md). +- **Source**: [teamshares/declarative_initialization](https://github.com/teamshares/declarative_initialization) +- **Code of conduct**: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) From 416c13878f10a11774fa009928a5d6ef73ab3c64 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 16:14:57 -0800 Subject: [PATCH 12/14] Update CHANGELOG.md --- CHANGELOG.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55ddcb..1a10318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,30 @@ ## [0.2.0] - 2026-02-19 -- **BREAKING:** Override when a method already exists - - Previously, if a method `#foo` existed (same class or ancestor), we skipped defining the reader and warned. Users had to use `@foo` to access the init-arg. - - Now, we **always define the reader**, overriding any existing method so `foo` consistently returns the init-arg value. -- Optional override warning in development/test (Rails) or when logger level is DEBUG -- No longer warns on Rails reload when the method was defined by us -- No longer warns when subclass re-declares an attribute from parent's `initialize_with` +### Changed +- `initialize_with` readers are now always defined, even if a method with the same name already exists. This makes `foo` consistently return the init-arg value. + +### Breaking +- Previously, if a method `#foo` existed (on the class or an ancestor), the gem skipped defining the reader and logged a warning; callers had to use `@foo` to access the init-arg. Now the reader is defined and overrides the existing method. + +### Added +- Optional override warnings in Rails development/test, or when logger level is `DEBUG`. + +### Fixed +- No warning on Rails reload when the existing method was originally defined by this gem. +- No warning when a subclass re-declares an attribute already declared by an ancestor’s `initialize_with`. +- Duplicate common mutable default values (`Array`, `Hash`, `Set`, `String`) per instance when the caller omits that keyword, preventing accidental cross-instance mutation. Copy is shallow; caller-provided values are not duplicated. + ## [0.1.1] - 2025-05-02 -- Refactor internals -- [BUGFIX] Only trigger `attr_reader` creation on initial class load (vs on every call to `#new`) +### Changed +- Refactor internals. + +### Fixed +- Only define `attr_reader`s on initial class setup (avoid re-defining on each call to `.new`). ## [0.1.0] - 2025-03-05 -- Initial release + +### Added +- Initial release. From f70fe27c3c370aeafe872037c752f97a952b3649 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 16:22:56 -0800 Subject: [PATCH 13/14] rubocop --- spec/alias_spec.rb | 1 + spec/basic_interface_spec.rb | 1 + spec/block_argument_spec.rb | 1 + spec/defaults_spec.rb | 2 ++ spec/downstream_inheritance_spec.rb | 1 + spec/post_initialize_block_spec.rb | 1 + spec/reader_spec.rb | 7 +++++++ 7 files changed, 14 insertions(+) diff --git a/spec/alias_spec.rb b/spec/alias_spec.rb index 613e4ef..76bbbe7 100644 --- a/spec/alias_spec.rb +++ b/spec/alias_spec.rb @@ -4,6 +4,7 @@ let(:klass) do Class.new do include InitializeWith + initialize_with :foo, bar: "default value" end end diff --git a/spec/basic_interface_spec.rb b/spec/basic_interface_spec.rb index 14df614..24049a1 100644 --- a/spec/basic_interface_spec.rb +++ b/spec/basic_interface_spec.rb @@ -37,6 +37,7 @@ let(:base_klass) do Class.new do include DeclarativeInitialization + initialize_with :foo, bar: "default value" end end diff --git a/spec/block_argument_spec.rb b/spec/block_argument_spec.rb index 8ae65f0..36699e9 100644 --- a/spec/block_argument_spec.rb +++ b/spec/block_argument_spec.rb @@ -4,6 +4,7 @@ let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo end end diff --git a/spec/defaults_spec.rb b/spec/defaults_spec.rb index 3f09fe4..0b2d42c 100644 --- a/spec/defaults_spec.rb +++ b/spec/defaults_spec.rb @@ -7,6 +7,7 @@ let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo, bar: foo end end @@ -18,6 +19,7 @@ let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo, bar: nil do @bar ||= foo end diff --git a/spec/downstream_inheritance_spec.rb b/spec/downstream_inheritance_spec.rb index 28b0dbc..4e807e3 100644 --- a/spec/downstream_inheritance_spec.rb +++ b/spec/downstream_inheritance_spec.rb @@ -6,6 +6,7 @@ let(:base_klass) do Class.new do include DeclarativeInitialization + initialize_with :foo, bar: "default value" end end diff --git a/spec/post_initialize_block_spec.rb b/spec/post_initialize_block_spec.rb index 7f9a981..6003b60 100644 --- a/spec/post_initialize_block_spec.rb +++ b/spec/post_initialize_block_spec.rb @@ -4,6 +4,7 @@ let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo, bar: "default value" do @baz = foo end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index 519b4cd..49d54ad 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -7,6 +7,7 @@ let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo def foo = @foo * 100 @@ -20,6 +21,7 @@ def foo = @foo * 100 let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo do @foo *= 100 end @@ -33,6 +35,7 @@ def foo = @foo * 100 let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo do @foo = foo * 100 end @@ -46,6 +49,7 @@ def foo = @foo * 100 let(:klass) do Class.new do include DeclarativeInitialization + initialize_with items: [] end end @@ -239,6 +243,7 @@ def bar = "user bar" let(:klass) do Class.new do include DeclarativeInitialization + initialize_with :foo def foo = "user-override-after" @@ -290,6 +295,7 @@ def foo = "user-override-after" let(:parent_klass) do Class.new do include DeclarativeInitialization + initialize_with :foo end end @@ -316,6 +322,7 @@ def foo = "user-override-after" let(:grandparent_klass) do Class.new do include DeclarativeInitialization + initialize_with :foo end end From 5d9b8c1333ed5a0a3f1b6987e67149d9f4501fb0 Mon Sep 17 00:00:00 2001 From: Kali Donovan Date: Thu, 19 Feb 2026 16:33:55 -0800 Subject: [PATCH 14/14] dep updates --- .github/workflows/main.yml | 2 +- Gemfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3a4835a..9d8ae51 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: - '3.2.2' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/Gemfile b/Gemfile index c65c53d..e932eec 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ source "https://rubygems.org" # Specify your gem's dependencies in declarative_initialization.gemspec gemspec -gem "pry-byebug", "~> 3.11.0" +gem "pry-byebug", "~> 3.12.0" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" gem "rubocop", "~> 1.21"