Module: Axn::Tools::Registry
Overview
Process-global tool registry: the registered adapter keys and every include-Axn class.
Instance Method Summary collapse
- #adapter_config_source(adapter) ⇒ Object
- #adapters ⇒ Object
-
#all_classes ⇒ Object
Only currently-defined, named classes survive.
-
#ensure_loaded! ⇒ Object
Ensures tool classes under each adapter's tool roots are loaded before enumeration.
-
#member?(klass, adapter) ⇒ Boolean
Membership = (directory grant ∪ declaration grant) − except.
-
#members(adapter, all_versions: false) ⇒ Object
adapteris vetted byAxn::Tools.for, which is how an adapter reaches this; a direct call here trusts its caller, and an unregistered key enumerates nothing rather than naming the mistake. -
#register_adapter(key, config_source = nil) ⇒ Object
A nil source on RE-registration keeps the existing source: the idempotent "ensure this adapter is registered" call (no source) must not wipe a source an adapter gem already supplied, which would strip its tool_roots and drop every directory-granted tool.
-
#register_class(klass) ⇒ Object
Called at include-Axn time (direct include) and inherited time (subclasses) for every action class.
- #reset_adapters! ⇒ Object
-
#tool_classes ⇒ Object
Every registered class that is a tool for ANY adapter, loaded dirs included.
-
#version_group(adapter, tool_name) ⇒ Object
The resolved version group for one logical tool under
adapter, or nil when nothing matches.
Instance Method Details
#adapter_config_source(adapter) ⇒ Object
28 29 30 |
# File 'lib/axn/tools/registry.rb', line 28 def adapter_config_source(adapter) _adapter_sources[adapter.to_sym] end |
#adapters ⇒ Object
24 25 26 |
# File 'lib/axn/tools/registry.rb', line 24 def adapters _adapter_sources.keys.to_set end |
#all_classes ⇒ Object
Only currently-defined, named classes survive. Every entry that isn't _currently_defined? is
deleted from _classes here, releasing its strong ref so a process-global Set can't pin dead
classes forever. That covers both cases _currently_defined? rejects: a stale NAMED reference
left by a Zeitwerk reload (the reloaded constant points at a fresh object), and a transient
anonymous class (name nil) that never got a constant. An anonymous class can never be a usable
tool anyway (no stable tool_name, no const_source_location for tool_root membership), and
members runs at adapter setup — well after class definition — so the "anonymous now, named
later" window is effectively never open at enumeration. Iterates a snapshot (_classes.to_a) so
a mid-enumeration registration can't corrupt the backing Set and deleting while walking is safe.
52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/axn/tools/registry.rb', line 52 def all_classes live = [] _classes.to_a.each do |klass| if _currently_defined?(klass) live << klass else _classes.delete(klass) end end live end |
#ensure_loaded! ⇒ Object
Ensures tool classes under each adapter's tool roots are loaded before enumeration.
Under Rails, unless eager-loading has already completed, hands each existing tool dir to
the main Zeitwerk loader via eager_load_dir; outside Rails, requires every .rb file
under each existing tool dir
individually. Both branches rescue StandardError, ScriptError — ScriptError covers
SyntaxError, LoadError, and NotImplementedError — so any load failure of one unit (a malformed
file, a missing require, a raising initializer) is isolated and warn-logged rather than
aborting enumeration. Isolation granularity differs by path: outside Rails, each require is
rescued independently, so one bad FILE is logged at warn and skipped without affecting its
siblings. Under Rails, eager_load_dir loads a DIRECTORY as a single unit — Zeitwerk has no
public API to load or require a managed file in isolation — so a file that raises aborts
the rest of that directory's files (logged at warn), while every other tool root
directory still loads independently.
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/axn/tools/registry.rb', line 123 def ensure_loaded! dirs = _all_adapter_dirs.select { |dir| File.directory?(dir) } return if dirs.empty? if _rails_app? # `config.eager_load` only says Rails INTENDS to eager-load; that phase runs late in boot # (after config/initializers). Skip the on-demand load only once the app has finished # initializing (eager-load has actually run), so an `Axn::Tools.for` call from within an # initializer still loads the tool dirs on demand. return if Rails.application.config.eager_load && Rails.application.respond_to?(:initialized?) && Rails.application.initialized? loader = Rails.autoloaders.main # The engine only pushes app/actions into Zeitwerk `after: :load_config_initializers` # (see Axn::RailsIntegration::Engine), so an `Axn::Tools.for` call from within a # `config/initializers` file runs BEFORE that hook — a configured tool dir can exist on # disk yet not be one Zeitwerk manages. `eager_load_dir` on an unmanaged dir would just # raise and get rescued below, silently yielding an empty/partial tool list. We don't push # dirs ourselves here (that's the engine's job, with its own namespace) — instead we check # Zeitwerk's own managed-root list (`loader.dirs`) up front and warn loudly so the caller # knows discovery may be incomplete, rather than degrading silently. managed_roots = loader.respond_to?(:dirs) ? loader.dirs : nil dirs.each { |dir| _eager_load_rails_dir(loader, dir, managed_roots) } else dirs.each do |dir| Dir.glob(File.join(dir, "**", "*.rb")).each do |file| # Snapshot _classes before the require so that if the file registers an Axn class (via # include/inherited) and THEN raises later in the same file, we can roll those # registrations back — otherwise a "skipped" file would still leak its classes into # `members`. The loop is single-threaded, so a before/after diff is exact. Scope the # rollback to classes SOURCED FROM this file: a dependency the file `require`d before # raising was registered in the same window but belongs to its own (valid) file, and # Ruby marks that file loaded so a later glob iteration would no-op — dropping it here # would leave the valid tool's constant defined yet permanently absent from _classes. before = _classes.dup require file rescue StandardError, ScriptError => e = File.(file) _rollback_registrations(before) { |src| src == } Axn.config.logger.warn do "[Axn] tool file skipped (#{_rendered_path(file)}): #{Axn::Internal::Rendering.class_name(e)}: " \ "#{Axn::Internal::Rendering.(e)}" end end end end rescue StandardError => e Axn.config.logger.warn do "[Axn] tool eager-load skipped: #{Axn::Internal::Rendering.class_name(e)}: #{Axn::Internal::Rendering.(e)}" end end |
#member?(klass, adapter) ⇒ Boolean
Membership = (directory grant ∪ declaration grant) − except. Directory grant: adapters whose
configured tool_roots contain the class's source file. Declaration grant: :all (every adapter),
or the explicit adapter list, or a tolerant configure(tool false and an
excepted adapter both short-circuit to non-membership.
179 180 181 182 183 184 185 186 187 188 |
# File 'lib/axn/tools/registry.rb', line 179 def member?(klass, adapter) return false unless klass.respond_to?(:_tool_declaration) decl = klass._tool_declaration return false if decl == false return false if klass._tool_except.include?(adapter) declared_grant = decl == :all || (decl.is_a?(Array) && decl.include?(adapter)) declared_grant || _under_adapter_root?(klass, adapter) || _declares_adapter_config?(klass, adapter) end |
#members(adapter, all_versions: false) ⇒ Object
adapter is vetted by Axn::Tools.for, which is how an adapter reaches this; a direct call here
trusts its caller, and an unregistered key enumerates nothing rather than naming the mistake.
76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/axn/tools/registry.rb', line 76 def members(adapter, all_versions: false) ensure_loaded! candidates = all_classes.select { |klass| member?(klass, adapter) } _assert_versioned_naming!(candidates) groups = _version_groups(candidates, adapter) if all_versions # Deterministic: by tool_name, then ascending version within each group. groups.sort_by(&:tool_name).flat_map(&:all) else # Latest per tool_name. Names are distinct after collapsing, so sort_by is tie-free. groups.map(&:latest).sort_by { |klass| klass.tool_name(adapter) } end end |
#register_adapter(key, config_source = nil) ⇒ Object
A nil source on RE-registration keeps the existing source: the idempotent "ensure this adapter is registered" call (no source) must not wipe a source an adapter gem already supplied, which would strip its tool_roots and drop every directory-granted tool. A non-nil source always updates (last-wins); a first-time nil registration stores nil (a declaration-driven adapter with no directory roots).
17 18 19 20 21 22 |
# File 'lib/axn/tools/registry.rb', line 17 def register_adapter(key, config_source = nil) key = key.to_sym return if config_source.nil? && _adapter_sources.key?(key) _adapter_sources[key] = config_source end |
#register_class(klass) ⇒ Object
Called at include-Axn time (direct include) and inherited time (subclasses) for every
action class. Idempotent: the backing Set drops a class already present, so a class
reachable via more than one path is never enumerated twice by members.
39 40 41 |
# File 'lib/axn/tools/registry.rb', line 39 def register_class(klass) _classes << klass end |
#reset_adapters! ⇒ Object
32 33 34 |
# File 'lib/axn/tools/registry.rb', line 32 def reset_adapters! @adapter_sources = {} end |
#tool_classes ⇒ Object
Every registered class that is a tool for ANY adapter, loaded dirs included. Separate from members
because validation is adapter-agnostic: a contract is valid or not, and asking per adapter would project
a class once per adapter it belongs to. Version groups are not collapsed either — each version is its own
class with its own contract, so each is worth validating.
68 69 70 71 72 |
# File 'lib/axn/tools/registry.rb', line 68 def tool_classes ensure_loaded! keys = adapters all_classes.select { |klass| keys.any? { |adapter| member?(klass, adapter) } } end |
#version_group(adapter, tool_name) ⇒ Object
The resolved version group for one logical tool under adapter, or nil when nothing
matches. Entry point for an adapter that needs one tool's versions by name (a path-routing
HTTP surface resolving /{tool_name}/v{n}) rather than the whole enumeration.
adapter is vetted by Axn::Tools.versions, which is how an adapter reaches this; a direct call
here trusts its caller, and an unregistered key resolves nothing rather than naming the mistake.
96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/axn/tools/registry.rb', line 96 def version_group(adapter, tool_name) ensure_loaded! target = tool_name.to_s candidates = all_classes.select { |klass| member?(klass, adapter) && klass.tool_name(adapter) == target } return nil if candidates.empty? # Validate the MATCHED members so this lookup never disagrees with `members`: a malformed # ::Vn member whose (explicit or derived) name matches `target` raises here too, exactly as # it would in `members`. Scoped to the matched set, so an unrelated malformed tool under a # different name can't derail the lookup. _assert_versioned_naming!(candidates) VersionGroup.new(adapter:, tool_name: target, members: candidates) end |