Module: Axn::Configurable

Includes:
PerClassOverrides
Defined in:
lib/axn/configurable.rb

Overview

A small DSL for declaring configuration on a module (e.g. a satellite gem namespace like Axn::MCP), so each one doesn't hand-roll its own config object, yielder, validation, and test reset.

module Axn::MCP
extend Axn::Configurable
setting :mcp_text_content, default: :structured, one_of: %i[structured message]
end

Axn::MCP.config.mcp_text_content        # => :structured
Axn::MCP.configure { |c| c.mcp_text_content = :message }
Axn::MCP.reset_config!                  # primarily for test isolation

Defined Under Namespace

Modules: ClassConfigWriter, PerClassOverrides, Settings Classes: Config, NamespaceWriter, Setting

Constant Summary collapse

UNSET =

Sentinel distinguishing "no argument given" from an explicit nil in the generated class-level override accessors.

Object.new.freeze
RESERVED_SETTING_NAMES =

Names the DSL installs itself, so a setting cannot be declared with one. Either direction of the collision silently breaks something: a generated reset! reader would replace the per-setting reset helper (leaving reset!(:other) an arity error), and in the module-singleton flavor Config#reset! wins method lookup so the declared setting becomes unreadable. Raise when the class is defined instead.

%i[reset!].freeze

Class Method Summary collapse

Instance Method Summary collapse

Methods included from PerClassOverrides

#_validate_override_setter!, #config_namespace, #overrides, #resolve_override_for

Class Method Details

.canonical_setting_name!(name) ⇒ Object

Canonicalizes a setting name to a Symbol and rejects the reserved ones, RETURNING the canonical form so every downstream use — the registry key, the ivar, the generated methods — derives from one to_sym. Calling to_sym again after the check would let a name whose to_sym answers differently each time pass the guard as one name and install itself as another, which is not only a way past the reserved list but a way to register a setting under a name whose reader and ivar disagree with it.

Raises:

  • (ArgumentError)


40
41
42
43
44
45
46
47
# File 'lib/axn/configurable.rb', line 40

def self.canonical_setting_name!(name)
  canonical = name.to_sym
  return canonical unless RESERVED_SETTING_NAMES.include?(canonical)

  raise ArgumentError,
        "setting #{canonical.inspect} is reserved: Axn::Configurable defines #{canonical} on every " \
        "config object. Pick another name."
end

.config_source_for(klass, namespace) ⇒ Object

The config source that owns namespace on klass or any ancestor, or nil. Walks the same superclass chain the override store uses, so the duplicate-owner guard and the configure writer agree on which source (if any) governs a namespace for a given class.



52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/axn/configurable.rb', line 52

def self.config_source_for(klass, namespace)
  while klass.is_a?(Module)
    if klass.instance_variable_defined?(:@_axn_config_sources)
      registry = klass.instance_variable_get(:@_axn_config_sources)
      return registry[namespace] if registry.key?(namespace)
    end
    break unless klass.is_a?(Class) && klass.superclass

    klass = klass.superclass
  end
  nil
end

.declared_settings_for(klass) ⇒ Object

Every Setting declared anywhere in klass's own ancestry (via Settings#setting), merged into one name => Setting map. Each class in the chain keeps its declarations in its own @_declared_settings ivar rather than one shared registry, so a subclass that declares additional settings — without re-extending Settings — still needs its instances to see both its own and every ancestor's. Reads the ivar directly (not through _declared_settings) so walking the chain never mints an empty registry on a class that never declared anything. A name declared on more than one class in the chain resolves to the most specific (deepest) declaration, since that class's Setting is merged in last.



73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/axn/configurable.rb', line 73

def self.declared_settings_for(klass)
  chain = []
  current = klass
  while current.is_a?(Class)
    chain << current
    current = current.superclass
  end

  chain.reverse_each.with_object({}) do |k, settings|
    settings.merge!(k.instance_variable_get(:@_declared_settings)) if k.instance_variable_defined?(:@_declared_settings)
  end
end

Instance Method Details

#configObject



466
467
468
# File 'lib/axn/configurable.rb', line 466

def config
  @_axn_config ||= Config.new(_axn_config_settings)
end

#configure {|config| ... } ⇒ Object

Yields:



470
471
472
473
# File 'lib/axn/configurable.rb', line 470

def configure
  yield(config) if block_given?
  config
end

#reset_config!Object



475
476
477
# File 'lib/axn/configurable.rb', line 475

def reset_config!
  @_axn_config = nil
end

#setting(name, default: nil, one_of: nil, validate: nil, overridable: false) ⇒ Object



458
459
460
461
462
463
464
# File 'lib/axn/configurable.rb', line 458

def setting(name, default: nil, one_of: nil, validate: nil, overridable: false)
  name = Axn::Configurable.canonical_setting_name!(name)
  setting = Setting.new(name:, default:, one_of:, validate:, overridable:)
  _axn_config_settings[name] = setting
  _define_override_methods(setting, -> { config.public_send(setting.name) }) if overridable
  nil
end