diff --git a/README.md b/README.md index 6c17651..d5dc09f 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Building modular DSLs shouldn't require reinventing the wheel. Stroma provides a Stroma is a foundation for library authors building DSL-driven frameworks (service objects, form objects, decorators, etc.). **Core lifecycle:** -1. **Register** - Define DSL modules at boot time via `Stroma::Registry` -2. **Compose** - Classes include `Stroma::DSL` to gain all registered modules automatically -3. **Extend** (optional) - Users can add cross-cutting logic via `before`/`after` hooks +1. **Define** - Create a Matrix with DSL modules at boot time +2. **Include** - Classes include the matrix's DSL to gain all modules +3. **Extend** (optional) - Add cross-cutting logic via `before`/`after` hooks ## 🚀 Quick Start @@ -55,16 +55,11 @@ spec.add_dependency "stroma", ">= 0.3" ```ruby module MyLib - module DSL - # Register DSL modules at load time - Stroma::Registry.register(:inputs, MyLib::Inputs::DSL) - Stroma::Registry.register(:actions, MyLib::Actions::DSL) - Stroma::Registry.finalize! - - def self.included(base) - base.include(Stroma::DSL) - end + STROMA = Stroma::Matrix.define(:my_lib) do + register :inputs, MyLib::Inputs::DSL + register :actions, MyLib::Actions::DSL end + private_constant :STROMA end ``` @@ -73,7 +68,7 @@ end ```ruby module MyLib class Base - include MyLib::DSL + include STROMA.dsl end end ``` diff --git a/Steepfile b/Steepfile index 6c92589..61c1f49 100644 --- a/Steepfile +++ b/Steepfile @@ -26,4 +26,8 @@ target :lib do # Complex splat delegation (*args) in fetch method causes type checking issues ignore "lib/stroma/settings/setting.rb" + + # Dynamic module generation via Module.new causes type checking issues + # Steep can't analyze methods inside Module.new blocks + ignore "lib/stroma/dsl/generator.rb" end diff --git a/lib/stroma/dsl.rb b/lib/stroma/dsl.rb deleted file mode 100644 index b1d4185..0000000 --- a/lib/stroma/dsl.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -module Stroma - # Main integration point between Stroma and service classes. - # - # ## Purpose - # - # Module that provides the core Stroma functionality to service classes: - # - Includes all registered DSL modules - # - Provides extensions block for hook registration - # - Handles inheritance with proper state copying - # - # ## Usage - # - # Library authors create a DSL module that includes Stroma::DSL: - # - # ```ruby - # module MyLib::DSL - # def self.included(base) - # base.include(Stroma::DSL) - # end - # end - # - # class MyLib::Base - # include MyLib::DSL - # - # extensions do - # before :actions, MyExtension - # end - # end - # ``` - # - # ## Extension Settings Access - # - # Extensions access their settings through the stroma.settings hierarchy: - # - # ```ruby - # # In ClassMethods: - # stroma.settings[:actions][:authorization][:method_name] = :authorize - # - # # In InstanceMethods: - # self.class.stroma.settings[:actions][:authorization][:method_name] - # ``` - # - # ## Integration - # - # Included by service classes that want Stroma hook functionality. - # Provides ClassMethods with: stroma, inherited, extensions. - module DSL - def self.included(base) - base.extend(ClassMethods) - - Registry.entries.each do |entry| - base.include(entry.extension) - end - end - - # Class-level methods for Stroma integration. - # - # ## Purpose - # - # Provides access to Stroma state and hooks DSL at the class level. - # Handles proper duplication during inheritance. - # - # ## Key Methods - # - # - `stroma` - Access the State container - # - `inherited` - Copy state to child classes - # - `extensions` - DSL block for hook registration - module ClassMethods - def self.extended(base) - base.instance_variable_set(:@stroma, State.new) - end - - # Handles inheritance by duplicating Stroma state. - # - # Creates an independent copy of hooks and settings for the child class, - # then applies all registered hooks to the child. - # - # @param child [Class] The child class being created - # @return [void] - def inherited(child) - super - - child.instance_variable_set(:@stroma, stroma.dup) - - Hooks::Applier.new(child, child.stroma.hooks).apply! - end - - # Returns the Stroma state for this service class. - # - # @return [State] The Stroma state container - # - # @example Accessing hooks - # stroma.hooks.before(:actions) - # - # @example Accessing settings - # stroma.settings[:actions][:authorization][:method_name] - def stroma - @stroma ||= State.new - end - - private - - # DSL block for registering hooks. - # - # Evaluates the block in the context of a Hooks::Factory, - # allowing before/after hook registration. - # - # @yield Block with before/after DSL calls - # @return [void] - # - # @example - # extensions do - # before :actions, AuthorizationExtension - # after :outputs, LoggingExtension - # end - def extensions(&block) - @stroma_hooks_factory ||= Hooks::Factory.new(stroma.hooks) - @stroma_hooks_factory.instance_eval(&block) - end - end - end -end diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb new file mode 100644 index 0000000..f1308ac --- /dev/null +++ b/lib/stroma/dsl/generator.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Stroma + module DSL + # Generates a DSL module scoped to a specific Matrix. + # + # ## Purpose + # + # Creates a module that: + # - Stores matrix reference on the module itself + # - Defines ClassMethods for service classes + # - Handles inheritance with state duplication + # + # Memory model: + # - Matrix owns @dsl_module (generated once, cached) + # - ServiceClass gets @stroma_matrix (same reference) + # - ServiceClass gets @stroma (unique State per class) + # + # ## Usage + # + # ```ruby + # # Called internally by Matrix#dsl + # dsl_module = Stroma::DSL::Generator.call(matrix) + # + # # The generated module is included in base classes + # class MyLib::Base + # include dsl_module + # end + # ``` + # + # ## Integration + # + # Called by Matrix#dsl to generate the DSL module. + # Generated module includes all registered extensions. + class Generator + class << self + # Generates a DSL module for the given matrix. + # + # @param matrix [Matrix] The matrix to generate DSL for + # @return [Module] The generated DSL module + def call(matrix) + new(matrix).generate + end + end + + # Creates a new generator for the given matrix. + # + # @param matrix [Matrix] The matrix to generate DSL for + def initialize(matrix) + @matrix = matrix + end + + # Generates the DSL module. + # + # Creates a module with ClassMethods that provides: + # - stroma_matrix accessor for matrix reference + # - stroma accessor for per-class state + # - inherited hook for state duplication + # - extensions DSL for registering hooks + # + # @return [Module] The generated DSL module + def generate # rubocop:disable Metrics/MethodLength + matrix = @matrix + class_methods = build_class_methods + + Module.new do + @stroma_matrix = matrix + + class << self + attr_reader :stroma_matrix + + def included(base) + mtx = stroma_matrix + base.extend(self::ClassMethods) + base.instance_variable_set(:@stroma_matrix, mtx) + base.instance_variable_set(:@stroma, State.new) + + mtx.entries.each { |entry| base.include(entry.extension) } + end + end + + const_set(:ClassMethods, class_methods) + end + end + + private + + # Builds the ClassMethods module. + # + # @return [Module] The ClassMethods module + def build_class_methods # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + Module.new do + attr_reader :stroma_matrix + + def stroma + @stroma ||= State.new + end + + def inherited(child) + super + child.instance_variable_set(:@stroma_matrix, stroma_matrix) + child.instance_variable_set(:@stroma, stroma.dup) + Hooks::Applier.apply!(child, child.stroma.hooks, stroma_matrix) + end + + private + + def extensions(&block) + @stroma_hooks_factory ||= Hooks::Factory.new(stroma.hooks, stroma_matrix) + @stroma_hooks_factory.instance_eval(&block) + end + end + end + end + end +end diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index d6449a7..33e3418 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -6,31 +6,46 @@ module Hooks # # ## Purpose # - # Iterates through all registered DSL modules and includes corresponding - # before/after hooks in the target class. For each registry entry, - # before hooks are included first, then after hooks. + # Includes hook extension modules into target class. + # Maintains order based on matrix registry entries. + # For each entry: before hooks first, then after hooks. # # ## Usage # # ```ruby - # applier = Stroma::Hooks::Applier.new(ChildService, hooks) + # # Called internally during class inheritance + # applier = Stroma::Hooks::Applier.new(ChildService, hooks, matrix) # applier.apply! - # # ChildService now includes all hook modules # ``` # # ## Integration # - # Called by Stroma::DSL.inherited after duplicating - # parent's configuration. Uses Registry.entries to determine - # hook application order. + # Called by DSL::Generator's inherited hook. + # Creates a temporary instance that is garbage collected after apply!. class Applier + class << self + # Applies all registered hooks to the target class. + # + # Convenience class method that creates an applier and applies hooks. + # + # @param target_class [Class] The class to apply hooks to + # @param hooks [Collection] The hooks collection to apply + # @param matrix [Matrix] The matrix providing registry entries + # @return [void] + def apply!(target_class, hooks, matrix) + new(target_class, hooks, matrix).apply! + end + end + # Creates a new applier for applying hooks to a class. # # @param target_class [Class] The class to apply hooks to # @param hooks [Collection] The hooks collection to apply - def initialize(target_class, hooks) + # @param matrix [Matrix] The matrix providing registry entries + def initialize(target_class, hooks, matrix) @target_class = target_class @hooks = hooks + @matrix = matrix end # Applies all registered hooks to the target class. @@ -39,21 +54,12 @@ def initialize(target_class, hooks) # then after hooks. Does nothing if hooks collection is empty. # # @return [void] - # - # @example - # applier.apply! - # # Target class now includes all extension modules def apply! return if @hooks.empty? - Registry.entries.each do |entry| - @hooks.before(entry.key).each do |hook| - @target_class.include(hook.extension) - end - - @hooks.after(entry.key).each do |hook| - @target_class.include(hook.extension) - end + @matrix.entries.each do |entry| + @hooks.before(entry.key).each { |hook| @target_class.include(hook.extension) } + @hooks.after(entry.key).each { |hook| @target_class.include(hook.extension) } end end end diff --git a/lib/stroma/hooks/factory.rb b/lib/stroma/hooks/factory.rb index 85d8beb..8656109 100644 --- a/lib/stroma/hooks/factory.rb +++ b/lib/stroma/hooks/factory.rb @@ -6,21 +6,16 @@ module Hooks # # ## Purpose # - # Provides the `before` and `after` methods used within the extensions - # block to register hooks. Validates that target keys exist in Registry - # before adding hooks. + # Provides before/after DSL methods for hook registration. + # Validates target keys against the matrix's registry. + # Delegates to Hooks::Collection for storage. # # ## Usage # - # Used within `extensions` block in classes that include Stroma::DSL: - # # ```ruby - # # Library Base class (includes Stroma::DSL via library's DSL module) - # class MyLib::Base - # include MyLib::DSL # MyLib::DSL includes Stroma::DSL - # + # class MyService < MyLib::Base # extensions do - # before :actions, ValidationModule + # before :actions, ValidationModule, AuthModule # after :outputs, LoggingModule # end # end @@ -28,15 +23,16 @@ module Hooks # # ## Integration # - # Created by DSL.extensions method and receives instance_eval of the block. - # Validates keys against Registry.keys and raises UnknownHookTarget - # for invalid keys. + # Created by DSL::Generator's extensions method. + # Cached as @stroma_hooks_factory on each service class. class Factory # Creates a new factory for registering hooks. # # @param hooks [Collection] The hooks collection to add to - def initialize(hooks) + # @param matrix [Matrix] The matrix providing valid keys + def initialize(hooks, matrix) @hooks = hooks + @matrix = matrix end # Registers one or more before hooks for a target key. @@ -44,6 +40,7 @@ def initialize(hooks) # @param key [Symbol] The registry key to hook before # @param extensions [Array] Extension modules to include # @raise [Exceptions::UnknownHookTarget] If key is not registered + # @return [void] # # @example # before :actions, ValidationModule, AuthorizationModule @@ -57,6 +54,7 @@ def before(key, *extensions) # @param key [Symbol] The registry key to hook after # @param extensions [Array] Extension modules to include # @raise [Exceptions::UnknownHookTarget] If key is not registered + # @return [void] # # @example # after :outputs, LoggingModule, AuditModule @@ -67,16 +65,17 @@ def after(key, *extensions) private - # Validates that the key exists in the Registry. + # Validates that the key exists in the matrix's registry. # # @param key [Symbol] The key to validate # @raise [Exceptions::UnknownHookTarget] If key is not registered + # @return [void] def validate_key!(key) - return if Registry.key?(key) + return if @matrix.key?(key) raise Exceptions::UnknownHookTarget, - "Unknown hook target: #{key.inspect}. " \ - "Valid keys: #{Registry.keys.map(&:inspect).join(', ')}" + "Unknown hook target #{key.inspect} for #{@matrix.name.inspect}. " \ + "Valid: #{@matrix.keys.map(&:inspect).join(', ')}" end end end diff --git a/lib/stroma/matrix.rb b/lib/stroma/matrix.rb new file mode 100644 index 0000000..fc2423b --- /dev/null +++ b/lib/stroma/matrix.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Stroma + # Main entry point for libraries using Stroma. + # + # ## Purpose + # + # Creates an isolated registry and generates a scoped DSL module. + # Each matrix has its own registry - no conflicts with other libraries. + # + # Lifecycle: + # - Boot time: Matrix.define creates Registry, registers extensions + # - Boot time: finalize! freezes registry, dsl generates Module + # - Boot time: freeze makes Matrix immutable + # - Runtime: All structures frozen, no allocations + # + # ## Usage + # + # ```ruby + # module MyLib + # STROMA = Stroma::Matrix.define(:my_lib) do + # register :inputs, Inputs::DSL + # register :outputs, Outputs::DSL + # end + # private_constant :STROMA + # end + # + # class MyLib::Base + # include MyLib::STROMA.dsl + # end + # ``` + # + # ## Integration + # + # Stored as a constant in the library's namespace. + # Owns the Registry and generates DSL module via DSL::Generator. + class Matrix + class << self + # Defines a new Matrix with given name. + # + # Preferred way to create a Matrix. Semantically indicates + # that we are defining an immutable DSL scope. + # + # @param name [Symbol, String] The matrix identifier + # @yield Block for registering DSL modules + # @return [Matrix] The frozen matrix instance + # + # @example + # STROMA = Stroma::Matrix.define(:my_lib) do + # register :inputs, Inputs::DSL + # register :outputs, Outputs::DSL + # end + def define(name, &block) + new(name, &block) + end + end + + # @!attribute [r] name + # @return [Symbol] The matrix identifier + # @!attribute [r] registry + # @return [Registry] The registry of DSL modules + # @!attribute [r] dsl + # @return [Module] The DSL module to include in base classes + attr_reader :name, :registry, :dsl + + # Creates a new Matrix with given name. + # + # Evaluates the block to register DSL modules, then finalizes + # the registry and freezes the matrix. + # + # @param name [Symbol, String] The matrix identifier + # @yield Block for registering DSL modules + def initialize(name, &block) + @name = name.to_sym + @registry = Registry.new(@name) + + instance_eval(&block) if block_given? + @registry.finalize! + @dsl = DSL::Generator.call(self) + freeze + end + + # Registers a DSL module with the given key. + # + # @param key [Symbol] The registry key + # @param extension [Module] The DSL module to register + # @return [void] + def register(key, extension) + @registry.register(key, extension) + end + + # Returns all registered entries. + # + # @return [Array] The registry entries + def entries + registry.entries + end + + # Returns all registered keys. + # + # @return [Array] The registry keys + def keys + registry.keys + end + + # Checks if a key is registered. + # + # @param key [Symbol] The key to check + # @return [Boolean] true if the key is registered + def key?(key) + registry.key?(key) + end + end +end diff --git a/lib/stroma/registry.rb b/lib/stroma/registry.rb index 20d39e8..6258882 100644 --- a/lib/stroma/registry.rb +++ b/lib/stroma/registry.rb @@ -1,63 +1,71 @@ # frozen_string_literal: true module Stroma - # Manages global registration of DSL modules for Stroma. + # Manages registration of DSL modules for a specific matrix. # # ## Purpose # - # Singleton registry that stores all DSL modules that will be included - # in service classes. Implements two-phase lifecycle: registration - # followed by finalization. + # Stores DSL module entries with their keys. + # Implements two-phase lifecycle: registration → finalization. + # Each Matrix has its own Registry - no global state. # # ## Usage # # ```ruby - # # During gem initialization: - # Stroma::Registry.register(:inputs, Inputs::DSL) - # Stroma::Registry.register(:outputs, Outputs::DSL) - # Stroma::Registry.finalize! + # registry = Stroma::Registry.new(:my_lib) + # registry.register(:inputs, Inputs::DSL) + # registry.register(:outputs, Outputs::DSL) + # registry.finalize! # - # # After finalization: - # Stroma::Registry.keys # => [:inputs, :outputs] - # Stroma::Registry.key?(:inputs) # => true + # registry.keys # => [:inputs, :outputs] + # registry.key?(:inputs) # => true # ``` # # ## Integration # - # Used by Stroma::DSL to include all registered modules in service classes. - # Used by Stroma::Hooks::Factory to validate hook target keys. - # - # ## Thread Safety - # - # Registration must occur during single-threaded boot phase. - # After finalization, all read operations are thread-safe. + # Created and owned by Matrix. + # Entries are accessed via Matrix#entries and Matrix#keys. class Registry - include Singleton + # @!attribute [r] matrix_name + # @return [Symbol] The name of the owning matrix + attr_reader :matrix_name - class << self - delegate :register, - :finalize!, - :entries, - :keys, - :key?, - to: :instance - end - - def initialize + # Creates a new registry for the given matrix. + # + # @param matrix_name [Symbol, String] The matrix identifier + def initialize(matrix_name) + @matrix_name = matrix_name.to_sym @entries = [] @finalized = false end + # Registers a DSL module with the given key. + # + # @param key [Symbol, String] The registry key + # @param extension [Module] The DSL module to register + # @raise [Exceptions::RegistryFrozen] If registry is finalized + # @raise [Exceptions::KeyAlreadyRegistered] If key already exists + # @return [void] def register(key, extension) - raise Exceptions::RegistryFrozen, "Registry is finalized" if @finalized + if @finalized + raise Exceptions::RegistryFrozen, + "Registry for #{@matrix_name.inspect} is finalized" + end + key = key.to_sym if @entries.any? { |e| e.key == key } - raise Exceptions::KeyAlreadyRegistered, "Key #{key.inspect} already registered" + raise Exceptions::KeyAlreadyRegistered, + "Key #{key.inspect} already registered in #{@matrix_name.inspect}" end @entries << Entry.new(key:, extension:) end + # Finalizes the registry, preventing further registrations. + # + # Idempotent - can be called multiple times safely. + # + # @return [void] def finalize! return if @finalized @@ -65,28 +73,45 @@ def finalize! @finalized = true end + # Returns all registered entries. + # + # @raise [Exceptions::RegistryNotFinalized] If not finalized + # @return [Array] The registry entries def entries ensure_finalized! @entries end + # Returns all registered keys. + # + # @raise [Exceptions::RegistryNotFinalized] If not finalized + # @return [Array] The registry keys def keys ensure_finalized! @entries.map(&:key) end + # Checks if a key is registered. + # + # @param key [Symbol, String] The key to check + # @raise [Exceptions::RegistryNotFinalized] If not finalized + # @return [Boolean] true if the key is registered def key?(key) ensure_finalized! - @entries.any? { |e| e.key == key } + @entries.any? { |e| e.key == key.to_sym } end private + # Ensures the registry is finalized. + # + # @raise [Exceptions::RegistryNotFinalized] If not finalized + # @return [void] def ensure_finalized! return if @finalized raise Exceptions::RegistryNotFinalized, - "Registry not finalized. Call Stroma::Registry.finalize! after registration." + "Registry for #{@matrix_name.inspect} not finalized" end end end diff --git a/sig/lib/stroma/dsl.rbs b/sig/lib/stroma/dsl.rbs deleted file mode 100644 index a982024..0000000 --- a/sig/lib/stroma/dsl.rbs +++ /dev/null @@ -1,22 +0,0 @@ -module Stroma - module DSL - def self.included: (Class base) -> void - - module ClassMethods - @stroma: State - @stroma_hooks_factory: Hooks::Factory - - def self.extended: (Class base) -> void - - # Note: child receives ClassMethods via inheritance, so it has stroma method - # Using untyped to allow stroma method call on child - def inherited: (untyped child) -> void - - def stroma: () -> State - - private - - def extensions: () { [self: Hooks::Factory] -> void } -> void - end - end -end diff --git a/sig/lib/stroma/dsl/generator.rbs b/sig/lib/stroma/dsl/generator.rbs new file mode 100644 index 0000000..8953dbe --- /dev/null +++ b/sig/lib/stroma/dsl/generator.rbs @@ -0,0 +1,17 @@ +module Stroma + module DSL + class Generator + @matrix: Matrix + + def self.call: (Matrix matrix) -> Module + + def initialize: (Matrix matrix) -> void + + def generate: () -> Module + + private + + def build_class_methods: () -> Module + end + end +end diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index ec5dae7..628e090 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -3,8 +3,11 @@ module Stroma class Applier @target_class: Class @hooks: Collection + @matrix: Matrix - def initialize: (Class target_class, Collection hooks) -> void + def self.apply!: (Class target_class, Collection hooks, Matrix matrix) -> void + + def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void def apply!: () -> void end diff --git a/sig/lib/stroma/hooks/factory.rbs b/sig/lib/stroma/hooks/factory.rbs index a39babd..4afec3a 100644 --- a/sig/lib/stroma/hooks/factory.rbs +++ b/sig/lib/stroma/hooks/factory.rbs @@ -2,8 +2,9 @@ module Stroma module Hooks class Factory @hooks: Collection + @matrix: Matrix - def initialize: (Collection hooks) -> void + def initialize: (Collection hooks, Matrix matrix) -> void def before: (Symbol key, *Module extensions) -> void diff --git a/sig/lib/stroma/matrix.rbs b/sig/lib/stroma/matrix.rbs new file mode 100644 index 0000000..e80c883 --- /dev/null +++ b/sig/lib/stroma/matrix.rbs @@ -0,0 +1,23 @@ +module Stroma + class Matrix + @name: Symbol + @registry: Registry + @dsl: Module + + attr_reader name: Symbol + attr_reader registry: Registry + attr_reader dsl: Module + + def self.define: (Symbol | String name) ?{ () -> void } -> Matrix + + def initialize: (Symbol | String name) ?{ () -> void } -> void + + def register: (Symbol key, Module extension) -> void + + def entries: () -> Array[Entry] + + def keys: () -> Array[Symbol] + + def key?: (Symbol key) -> bool + end +end diff --git a/sig/lib/stroma/registry.rbs b/sig/lib/stroma/registry.rbs index 2d6993f..a631fa8 100644 --- a/sig/lib/stroma/registry.rbs +++ b/sig/lib/stroma/registry.rbs @@ -1,19 +1,14 @@ module Stroma class Registry - include Singleton - + @matrix_name: Symbol @entries: Array[Entry] @finalized: bool - def self.register: (Symbol key, Module extension) -> void - def self.finalize!: () -> void - def self.entries: () -> Array[Entry] - def self.keys: () -> Array[Symbol] - def self.key?: (Symbol key) -> bool + attr_reader matrix_name: Symbol - def initialize: () -> void + def initialize: (Symbol | String matrix_name) -> void - def register: (Symbol key, Module extension) -> void + def register: (Symbol | String key, Module extension) -> void def finalize!: () -> void @@ -21,7 +16,7 @@ module Stroma def keys: () -> Array[Symbol] - def key?: (Symbol key) -> bool + def key?: (Symbol | String key) -> bool private diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 44bf7d6..716e4fe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -27,8 +27,4 @@ # doing truncation. c.max_formatted_output_length = nil end - - config.before(:suite) do - StromaTestRegistry.setup! - end end diff --git a/spec/stroma/dsl_spec.rb b/spec/stroma/dsl/generator_spec.rb similarity index 59% rename from spec/stroma/dsl_spec.rb rename to spec/stroma/dsl/generator_spec.rb index 9c78617..ec8aa8f 100644 --- a/spec/stroma/dsl_spec.rb +++ b/spec/stroma/dsl/generator_spec.rb @@ -1,30 +1,54 @@ # frozen_string_literal: true -RSpec.describe Stroma::DSL do - describe ".included" do +RSpec.describe Stroma::DSL::Generator do + let(:inputs_dsl) { Module.new } + let(:outputs_dsl) { Module.new } + + let(:matrix) do + inputs = inputs_dsl + outputs = outputs_dsl + Stroma::Matrix.define(:test) do + register :inputs, inputs + register :outputs, outputs + end + end + + describe ".call" do + let(:dsl_module) { described_class.call(matrix) } + + it "returns a module" do + expect(dsl_module).to be_a(Module) + end + + it "stores matrix reference" do + expect(dsl_module.stroma_matrix).to eq(matrix) + end + end + + describe "generated module" do let(:base_class) do - Class.new do - include Stroma::DSL - end + mtx = matrix + Class.new { include mtx.dsl } end - it "extends the class with ClassMethods", :aggregate_failures do + it "extends class with ClassMethods", :aggregate_failures do expect(base_class).to respond_to(:stroma) + expect(base_class).to respond_to(:stroma_matrix) expect(base_class).to respond_to(:inherited) end - it "includes all registered DSL modules" do - Stroma::Registry.entries.each do |entry| - expect(base_class.ancestors).to( - include(entry.extension), - "Expected ancestors to include #{entry.extension} (key: #{entry.key})" - ) - end + it "includes all registered DSL modules", :aggregate_failures do + expect(base_class.ancestors).to include(inputs_dsl) + expect(base_class.ancestors).to include(outputs_dsl) end - it "creates a stroma state" do + it "creates stroma state" do expect(base_class.stroma).to be_a(Stroma::State) end + + it "stores matrix reference on class" do + expect(base_class.stroma_matrix).to eq(matrix) + end end describe "inheritance" do @@ -37,12 +61,13 @@ def self.included(base) end let(:base_class) do + mtx = matrix ext = extension_module Class.new do - include Stroma::DSL + include mtx.dsl extensions do - before :actions, ext + before :inputs, ext end end end @@ -63,35 +88,8 @@ def self.included(base) expect(child_class.stroma).to be_a(Stroma::State) end - it "hooks are inherited" do - grandchild = Class.new(child_class) - expect(grandchild.ancestors).to include(extension_module) - end - end - - describe "#extensions" do - let(:first_module) { Module.new } - let(:second_module) { Module.new } - - let(:base_class) do - first_ext = first_module - second_ext = second_module - Class.new do - include Stroma::DSL - - extensions do - before :actions, first_ext - after :outputs, second_ext - end - end - end - - it "registers before hooks" do - expect(base_class.stroma.hooks.before(:actions).size).to eq(1) - end - - it "registers after hooks" do - expect(base_class.stroma.hooks.after(:outputs).size).to eq(1) + it "preserves matrix reference" do + expect(child_class.stroma_matrix).to eq(matrix) end end @@ -99,12 +97,13 @@ def self.included(base) let(:extension_module) { Module.new } let(:parent_class) do + mtx = matrix ext = extension_module Class.new do - include Stroma::DSL + include mtx.dsl extensions do - before :actions, ext + before :inputs, ext end end end @@ -125,21 +124,21 @@ def self.included(base) end it "child inherits parent hooks", :aggregate_failures do - expect(child_class.stroma.hooks.before(:actions).size).to eq(1) + expect(child_class.stroma.hooks.before(:inputs).size).to eq(1) expect(child_class.ancestors).to include(extension_module) end it "parent modifications after child creation do not affect child" do - child_before_count = child_class.stroma.hooks.before(:inputs).size + child_before_count = child_class.stroma.hooks.before(:outputs).size new_extension = Module.new parent_class.class_eval do extensions do - before :inputs, new_extension + before :outputs, new_extension end end - expect(child_class.stroma.hooks.before(:inputs).size).to eq(child_before_count) + expect(child_class.stroma.hooks.before(:outputs).size).to eq(child_before_count) end end end diff --git a/spec/stroma/hooks/applier_spec.rb b/spec/stroma/hooks/applier_spec.rb index f8572b9..93b98d6 100644 --- a/spec/stroma/hooks/applier_spec.rb +++ b/spec/stroma/hooks/applier_spec.rb @@ -1,83 +1,82 @@ # frozen_string_literal: true RSpec.describe Stroma::Hooks::Applier do + let(:inputs_dsl) { Module.new } + let(:outputs_dsl) { Module.new } + let(:matrix) do + inputs = inputs_dsl + outputs = outputs_dsl + Stroma::Matrix.define(:test) do + register :inputs, inputs + register :outputs, outputs + end + end + let(:hooks) { Stroma::Hooks::Collection.new } let(:target_class) { Class.new } - let(:applier) { described_class.new(target_class, hooks) } + let(:applier) { described_class.new(target_class, hooks, matrix) } + + describe ".apply!" do + let(:before_extension) { Module.new } + + before do + hooks.add(:before, :inputs, before_extension) + end + + it "applies hooks via class method" do + described_class.apply!(target_class, hooks, matrix) + expect(target_class.ancestors).to include(before_extension) + end + end describe "#apply!" do - context "when hooks are empty" do - it "does nothing" do - allow(target_class).to receive(:include) - applier.apply! - expect(target_class).not_to have_received(:include) - end + it "does nothing when hooks empty" do + applier.apply! + expect(target_class.ancestors).not_to include(inputs_dsl) end - context "when hooks are present" do - let(:before_module) { Module.new } - let(:after_module) { Module.new } + context "with before hooks" do + let(:before_extension) { Module.new } before do - hooks.add(:before, :actions, before_module) - hooks.add(:after, :outputs, after_module) - end - - it "includes before hooks" do - applier.apply! - expect(target_class.ancestors).to include(before_module) + hooks.add(:before, :inputs, before_extension) end - it "includes after hooks" do + it "includes before hook extension" do applier.apply! - expect(target_class.ancestors).to include(after_module) + expect(target_class.ancestors).to include(before_extension) end end - context "with multiple hooks for same key" do - let(:first_module) { Module.new } - let(:second_module) { Module.new } + context "with after hooks" do + let(:after_extension) { Module.new } before do - hooks.add(:before, :actions, first_module) - hooks.add(:before, :actions, second_module) + hooks.add(:after, :outputs, after_extension) end - it "includes all hooks in order" do + it "includes after hook extension" do applier.apply! - expect(target_class.ancestors).to include(first_module, second_module) + expect(target_class.ancestors).to include(after_extension) end end - context "with before and after hooks for same key" do - let(:inclusion_order) { [] } - - let(:before_module) do - order = inclusion_order - Module.new do - define_singleton_method(:included) do |_base| - order << :before - end - end - end - - let(:after_module) do - order = inclusion_order - Module.new do - define_singleton_method(:included) do |_base| - order << :after - end - end - end + context "with multiple hooks" do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:before_inputs) { Module.new } + let(:after_inputs) { Module.new } + let(:before_outputs) { Module.new } before do - hooks.add(:before, :actions, before_module) - hooks.add(:after, :actions, after_module) + hooks.add(:before, :inputs, before_inputs) + hooks.add(:after, :inputs, after_inputs) + hooks.add(:before, :outputs, before_outputs) end - it "includes before hooks before after hooks" do + it "applies all hooks", :aggregate_failures do applier.apply! - expect(inclusion_order).to eq(%i[before after]) + expect(target_class.ancestors).to include(before_inputs) + expect(target_class.ancestors).to include(after_inputs) + expect(target_class.ancestors).to include(before_outputs) end end end diff --git a/spec/stroma/hooks/factory_spec.rb b/spec/stroma/hooks/factory_spec.rb index 786bd5d..ed59922 100644 --- a/spec/stroma/hooks/factory_spec.rb +++ b/spec/stroma/hooks/factory_spec.rb @@ -2,7 +2,14 @@ RSpec.describe Stroma::Hooks::Factory do let(:hooks) { Stroma::Hooks::Collection.new } - let(:factory) { described_class.new(hooks) } + let(:matrix) do + Stroma::Matrix.define(:test) do + register :inputs, Module.new + register :outputs, Module.new + register :actions, Module.new + end + end + let(:factory) { described_class.new(hooks, matrix) } let(:first_module) { Module.new } let(:second_module) { Module.new } @@ -21,8 +28,7 @@ it "raises UnknownHookTarget for unknown key" do expect { factory.before(:unknown, first_module) }.to raise_error( Stroma::Exceptions::UnknownHookTarget, - "Unknown hook target: :unknown. " \ - "Valid keys: :configuration, :info, :context, :inputs, :internals, :outputs, :actions" + "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" ) end end @@ -42,15 +48,14 @@ it "raises UnknownHookTarget for unknown key" do expect { factory.after(:unknown, first_module) }.to raise_error( Stroma::Exceptions::UnknownHookTarget, - "Unknown hook target: :unknown. " \ - "Valid keys: :configuration, :info, :context, :inputs, :internals, :outputs, :actions" + "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" ) end end describe "valid keys" do it "accepts all registered keys" do - %i[configuration info context inputs internals outputs actions].each do |key| + %i[inputs outputs actions].each do |key| expect { factory.before(key, first_module) }.not_to raise_error end end diff --git a/spec/stroma/matrix_spec.rb b/spec/stroma/matrix_spec.rb new file mode 100644 index 0000000..7c7293e --- /dev/null +++ b/spec/stroma/matrix_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Matrix do + describe ".define" do + it "creates a frozen matrix with name", :aggregate_failures do + matrix = described_class.define(:test) do + register :inputs, Module.new + end + + expect(matrix.name).to eq(:test) + expect(matrix).to be_frozen + end + + it "is equivalent to .new", :aggregate_failures do + inputs_mod = Module.new + + matrix_via_define = described_class.define(:test) do + register :inputs, inputs_mod + end + + matrix_via_new = described_class.new(:test) do + register :inputs, inputs_mod + end + + expect(matrix_via_define.name).to eq(matrix_via_new.name) + expect(matrix_via_define.keys).to eq(matrix_via_new.keys) + end + end + + describe "#initialize" do + it "creates a frozen matrix with name", :aggregate_failures do + matrix = described_class.new(:test) do + register :inputs, Module.new + end + + expect(matrix.name).to eq(:test) + expect(matrix).to be_frozen + end + + it "finalizes registry automatically", :aggregate_failures do + matrix = described_class.new(:test) do + register :inputs, Module.new + end + + expect(matrix.registry).to be_a(Stroma::Registry) + expect(matrix.keys).to eq([:inputs]) + end + end + + describe "#register" do + it "delegates to registry" do + matrix = described_class.new(:test) do + register :inputs, Module.new + register :outputs, Module.new + end + + expect(matrix.keys).to eq(%i[inputs outputs]) + end + end + + describe "#dsl" do + let(:matrix) do + described_class.new(:test) do + register :inputs, Module.new + end + end + + it "returns a module" do + expect(matrix.dsl).to be_a(Module) + end + end + + describe "isolation" do + let(:matrix_a) do + described_class.new(:lib_a) do + register :inputs, Module.new + register :outputs, Module.new + end + end + + let(:matrix_b) do + described_class.new(:lib_b) do + register :inputs, Module.new + register :events, Module.new + end + end + + it "has independent registries", :aggregate_failures do + expect(matrix_a.keys).to eq(%i[inputs outputs]) + expect(matrix_b.keys).to eq(%i[inputs events]) + end + + it "allows same keys in different matrices" do + expect do + matrix_a + matrix_b + end.not_to raise_error + end + end +end diff --git a/spec/stroma/registry_spec.rb b/spec/stroma/registry_spec.rb index 5ce9c64..269982a 100644 --- a/spec/stroma/registry_spec.rb +++ b/spec/stroma/registry_spec.rb @@ -1,59 +1,109 @@ # frozen_string_literal: true RSpec.describe Stroma::Registry do - # NOTE: Registry is a Singleton and is already populated by StromaTestRegistry. - # We test using the already-finalized registry. - - describe ".entries" do - it "returns all registered entries", :aggregate_failures do - expect(described_class.entries).to be_an(Array) - expect(described_class.entries).not_to be_empty + describe "#initialize" do + it "creates registry with matrix name" do + registry = described_class.new(:test) + expect(registry.matrix_name).to eq(:test) end + end + + describe "#register" do + let(:registry) { described_class.new(:test) } - it "contains DSL modules in order" do - keys = described_class.entries.map(&:key) - expect(keys).to eq(%i[configuration info context inputs internals outputs actions]) + it "adds entry to registry" do + extension = Module.new + registry.register(:inputs, extension) + registry.finalize! + + expect(registry.keys).to eq([:inputs]) end - end - describe ".keys" do - it "returns all registered keys" do - expect(described_class.keys).to eq(%i[configuration info context inputs internals outputs actions]) + it "raises KeyAlreadyRegistered for duplicate key" do + registry.register(:inputs, Module.new) + + expect { registry.register(:inputs, Module.new) }.to raise_error( + Stroma::Exceptions::KeyAlreadyRegistered, + "Key :inputs already registered in :test" + ) end - end - describe ".register" do - it "raises RegistryFrozen when registry is finalized" do - expect { described_class.register(:test, Module.new) }.to raise_error( + it "raises RegistryFrozen when finalized" do + registry.finalize! + + expect { registry.register(:test, Module.new) }.to raise_error( Stroma::Exceptions::RegistryFrozen, - "Registry is finalized" + "Registry for :test is finalized" ) end end - describe ".key?" do - it "returns true for registered key" do - expect(described_class.key?(:inputs)).to be(true) + describe "#finalize!" do + let(:registry) { described_class.new(:test) } + + it "is idempotent" do + registry.register(:inputs, Module.new) + registry.finalize! + + expect { registry.finalize! }.not_to raise_error end - it "returns true for all registered keys" do - %i[configuration info context inputs internals outputs actions].each do |key| - expect(described_class.key?(key)).to be(true) - end + it "freezes entries" do + registry.register(:inputs, Module.new) + registry.finalize! + + expect(registry.entries).to be_frozen end + end - it "returns false for unregistered key" do - expect(described_class.key?(:unknown)).to be(false) + describe "#entries" do + let(:registry) { described_class.new(:test) } + + it "raises RegistryNotFinalized before finalize" do + registry.register(:inputs, Module.new) + + expect { registry.entries }.to raise_error( + Stroma::Exceptions::RegistryNotFinalized, + "Registry for :test not finalized" + ) + end + + it "returns entries after finalize", :aggregate_failures do + extension = Module.new + registry.register(:inputs, extension) + registry.finalize! + + expect(registry.entries.first.key).to eq(:inputs) + expect(registry.entries.first.extension).to eq(extension) end + end + + describe "#keys" do + let(:registry) { described_class.new(:test) } + + it "returns all registered keys" do + registry.register(:inputs, Module.new) + registry.register(:outputs, Module.new) + registry.finalize! - it "returns false for nil" do - expect(described_class.key?(nil)).to be(false) + expect(registry.keys).to eq(%i[inputs outputs]) end end - describe ".finalize!" do - it "is idempotent - can be called multiple times" do - expect { described_class.finalize! }.not_to raise_error + describe "#key?" do + let(:registry) { described_class.new(:test) } + + before do + registry.register(:inputs, Module.new) + registry.finalize! + end + + it "returns true for registered key" do + expect(registry.key?(:inputs)).to be(true) + end + + it "returns false for unregistered key" do + expect(registry.key?(:unknown)).to be(false) end end end diff --git a/spec/support/stroma_test_registry.rb b/spec/support/stroma_test_registry.rb deleted file mode 100644 index 20513a5..0000000 --- a/spec/support/stroma_test_registry.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module StromaTestRegistry - MOCK_MODULES = { - configuration: Module.new, - info: Module.new, - context: Module.new, - inputs: Module.new, - internals: Module.new, - outputs: Module.new, - actions: Module.new - }.freeze - - def self.setup! - return if Stroma::Registry.instance.instance_variable_get(:@finalized) - - MOCK_MODULES.each do |key, mod| - Stroma::Registry.register(key, mod) - end - Stroma::Registry.finalize! - end -end