From 5552b1dc4c22919ba5c6c2cbee29aaf072b01e79 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Fri, 14 Aug 2026 21:05:54 -0400 Subject: [PATCH] Fix NoMethodError when a subclass defines no enums of its own Ruby::Enum's class methods (keys, values, key?, value?, key, value, to_h, parse, each) relied directly on @_enum_hash/@_enums_by_value instance variables, which are only set the first time define is called. A subclass that includes Ruby::Enum only through inheritance and never calls define itself never gets these ivars set, so calling any of the above methods raised NoMethodError on nil. Introduces private _enum_hash/_enums_by_value reader methods that default to an empty hash, and uses them everywhere instead of the raw instance variables. Closes #49. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + lib/ruby-enum/enum.rb | 45 +++++++++++++++++++++++-------------- spec/ruby-enum/enum_spec.rb | 31 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dce9d55..a33a036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 1.2.0 (Next) +* [#49](https://github.com/dblock/ruby-enum/issues/49): Fixed `NoMethodError` on `keys`, `values`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` when a subclass defines no enums of its own - [@dblock](https://github.com/dblock). * Your contribution here. ### 1.1.0 (2026/6/20) diff --git a/lib/ruby-enum/enum.rb b/lib/ruby-enum/enum.rb index 6693b66..13ef828 100644 --- a/lib/ruby-enum/enum.rb +++ b/lib/ruby-enum/enum.rb @@ -31,9 +31,6 @@ module ClassMethods # [key] Enumerator key. # [value] Enumerator value. def define(key, value = key) - @_enum_hash ||= {} - @_enums_by_value ||= {} - validate_key!(key) validate_value!(value) @@ -48,8 +45,8 @@ def define(key, value = key) def store_new_instance(key, value) new_instance = new(key, value) - @_enum_hash[key] = new_instance - @_enums_by_value[value] = new_instance + _enum_hash[key] = new_instance + _enums_by_value[value] = new_instance end def const_missing(key) @@ -59,7 +56,7 @@ def const_missing(key) # Iterate over all enumerated values. # Required for Enumerable mixin def each(&block) - @_enum_hash.each(&block) + _enum_hash.each(&block) end # Attempt to parse an enum key and return the @@ -84,7 +81,7 @@ def parse(k) # # Returns true if the key exists, false otherwise. def key?(k) - @_enum_hash.key?(k) + _enum_hash.key?(k) end # Gets the string value for the specified key. @@ -94,7 +91,7 @@ def key?(k) # # Returns the corresponding enum instance or nil. def value(k) - enum = @_enum_hash[k] + enum = _enum_hash[k] enum&.value end @@ -105,7 +102,7 @@ def value(k) # # Returns true if the value exists, false otherwise. def value?(v) - @_enums_by_value.key?(v) + _enums_by_value.key?(v) end # Gets the key symbol for the specified value. @@ -115,18 +112,18 @@ def value?(v) # # Returns the corresponding key symbol or nil. def key(v) - enum = @_enums_by_value[v] + enum = _enums_by_value[v] enum&.key end # Returns all enum keys. def keys - @_enum_hash.values.map(&:key) + _enum_hash.values.map(&:key) end # Returns all enum values. def values - result = @_enum_hash.values.map(&:value) + result = _enum_hash.values.map(&:value) if superclass < Ruby::Enum superclass.values + result @@ -138,7 +135,7 @@ def values # Iterate over all enumerated values. # Required for Enumerable mixin def each_value(&_block) - @_enum_hash.each_value do |v| + _enum_hash.each_value do |v| yield v.value end end @@ -146,29 +143,43 @@ def each_value(&_block) # Iterate over all enumerated keys. # Required for Enumerable mixin def each_key(&_block) - @_enum_hash.each_value do |v| + _enum_hash.each_value do |v| yield v.key end end def to_h - @_enum_hash.transform_values(&:value) + _enum_hash.transform_values(&:value) end private + # Returns this class' own enum hash, defaulting to an empty hash. + # + # A subclass that does not `define` any of its own enums does not have + # its `@_enum_hash` instance variable set, since it's only initialized + # in `define` and in the `included` hook. + def _enum_hash + @_enum_hash ||= {} + end + + # Returns this class' own enums-by-value hash, defaulting to an empty hash. + def _enums_by_value + @_enums_by_value ||= {} + end + def upper?(s) !/[[:upper:]]/.match(s).nil? end def validate_key!(key) - return unless @_enum_hash.key?(key) + return unless _enum_hash.key?(key) raise Ruby::Enum::Errors::DuplicateKeyError, name: name, key: key end def validate_value!(value) - return unless @_enums_by_value.key?(value) + return unless _enums_by_value.key?(value) raise Ruby::Enum::Errors::DuplicateValueError, name: name, value: value end diff --git a/spec/ruby-enum/enum_spec.rb b/spec/ruby-enum/enum_spec.rb index 0003700..5262ff4 100644 --- a/spec/ruby-enum/enum_spec.rb +++ b/spec/ruby-enum/enum_spec.rb @@ -24,6 +24,9 @@ class OtherSecondSubclass < FirstSubclass define :MAGENTA, 'magenta' end + class SubclassWithNoOwnDefines < Colors + end + it 'returns an enum value' do expect(Colors::RED).to eq 'red' expect(Colors::GREEN).to eq 'green' @@ -182,6 +185,12 @@ class OtherSecondSubclass < FirstSubclass expect(OtherSecondSubclass.values).to eq(%w[red green orange magenta]) end end + + context 'when a subclass defines no enums of its own' do + it 'returns the values from the parent class' do + expect(SubclassWithNoOwnDefines.values).to eq(%w[red green]) + end + end end describe '#to_h' do @@ -334,6 +343,28 @@ class EmptyEnums expect(FirstSubclass.values).to eq(%w[red green orange]) end end + + context 'when a subclass defines no enums of its own (issue #49)' do + it 'inherits the parent class enums via constants' do + expect(SubclassWithNoOwnDefines::RED).to eq 'red' + expect(SubclassWithNoOwnDefines::GREEN).to eq 'green' + end + + it 'inherits the parent class values' do + expect(SubclassWithNoOwnDefines.values).to eq(%w[red green]) + end + + it 'does not raise when calling keys, key?, value?, key, value, to_h, parse or each' do + expect(SubclassWithNoOwnDefines.keys).to eq([]) + expect(SubclassWithNoOwnDefines.key?(:RED)).to be false + expect(SubclassWithNoOwnDefines.value?('red')).to be false + expect(SubclassWithNoOwnDefines.key('red')).to be_nil + expect(SubclassWithNoOwnDefines.value(:RED)).to be_nil + expect(SubclassWithNoOwnDefines.to_h).to eq({}) + expect(SubclassWithNoOwnDefines.parse('red')).to be_nil + expect(SubclassWithNoOwnDefines.each.to_a).to eq([]) + end + end end describe 'default value' do