From c0b6f0fbd8511b6cede891ce53257edab11f6727 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Mon, 27 Jul 2026 14:30:03 -0400 Subject: [PATCH 1/3] Register cycle kinds explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking of handler classes moves out of Cycle and into a registry, and subclassing no longer registers anything. A class joins by declaring what it handles: handles :lookback, notation: "L", periods: %w[D W M Y] which sets the kind, notation id, periods and volume-only flag and registers in one step, so declaration and registration cannot drift apart. Inheriting from Cycle for any other reason — an abstract intermediate, a test double — now carries no hidden side effect. The parser's pattern is built from the registry rather than written out, so an application can declare a kind of its own and have its notation parse without reopening this gem. Registering a class displaces any already answering to the same kind or notation id, which is what lets an application replace a built-in rather than only add alongside it. TimeSpan::DatePeriod keeps its own inherited hook, and period keys remain a closed set; both are left for a follow-up. Added: `Cycle.handles` declares and registers the kind a class handles Added: `SOF::CycleRegistry`, reachable as `Cycle.registry`, so an application can add or replace a cycle kind Changed: cycle classes register by declaring rather than by subclassing; `Cycle.cycle_handlers` is gone Removed: `Parser::PARTS_REGEX`, replaced by `Parser.parts_regex` built from the registered kinds Version: minor --- lib/sof/cycle.rb | 60 +++++++++----- lib/sof/cycle/parser.rb | 37 +++++---- lib/sof/cycle_registry.rb | 84 +++++++++++++++++++ lib/sof/cycles/calendar.rb | 5 +- lib/sof/cycles/end_of.rb | 5 +- lib/sof/cycles/interval.rb | 5 +- lib/sof/cycles/lookback.rb | 5 +- lib/sof/cycles/lookback_end_of.rb | 5 +- lib/sof/cycles/volume_only.rb | 5 +- lib/sof/cycles/within.rb | 5 +- spec/sof/custom_cycle_kind_spec.rb | 124 +++++++++++++++++++++++++++++ spec/sof/cycle_registry_spec.rb | 118 +++++++++++++++++++++++++++ 12 files changed, 395 insertions(+), 63 deletions(-) create mode 100644 lib/sof/cycle_registry.rb create mode 100644 spec/sof/custom_cycle_kind_spec.rb create mode 100644 spec/sof/cycle_registry_spec.rb diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 140effb..b4c9ae6 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "forwardable" +require_relative "cycle_registry" require_relative "cycle/parser" module SOF @@ -70,9 +71,7 @@ def for(notation) raise InvalidInput, "'#{notation}' is not a valid input" end - cycle = Cycle.cycle_handlers.find do |klass| - parser.parses?(klass.notation_id) - end.new(notation, parser:) + cycle = registry.for_notation_id(parser.kind).new(notation, parser:) return cycle if parser.active? Cycles::Dormant.new(cycle, parser:) @@ -84,22 +83,14 @@ def for(notation) # @example # class_for_notation_id('L') # - def class_for_notation_id(notation_id) - Cycle.cycle_handlers.find do |klass| - klass.notation_id == notation_id - end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}") - end + def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id) # Return the class handling the kind # # @param sym [Symbol] symbol matching the kind of Cycle class # @example # class_for_kind(:lookback) - def class_for_kind(sym) - Cycle.cycle_handlers.find do |klass| - klass.handles?(sym) - end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle") - end + def class_for_kind(sym) = registry.handling(sym) # Return a legend explaining all notation components # @@ -123,6 +114,8 @@ def legend } end + # Defaults for the base class itself, which handles no kind and so is + # never registered. @volume_only = false @notation_id = nil @kind = nil @@ -150,23 +143,48 @@ def handles?(sym) kind.to_s == sym.to_s end - def cycle_handlers - @cycle_handlers ||= Set.new + # Declare what kind of cycle this class handles, and register it. + # + # Registration is a consequence of declaring, so the two can never + # disagree, and subclassing Cycle for any other reason — an abstract + # intermediate, a test double — registers nothing. + # + # An application can declare its own kind this way and the parser will + # recognise its notation, because the pattern is built from what is + # registered. + # + # @param kind [Symbol] the kind this class handles, e.g. :lookback + # @param notation [String, nil] the notation id opening the cycle, e.g. + # "L". Omit for a cycle with no notation of its own, such as + # volume-only. + # @param periods [Array] period keys this kind accepts. Empty + # means the kind takes no period. + # @param volume_only [Boolean] whether this kind carries volume alone. + # + # @example + # class Fortnightly < SOF::Cycle + # handles :fortnightly, notation: "X", periods: %w[D W] + # def self.recurring? = true + # end + def handles(kind, notation: nil, periods: [], volume_only: false) + @kind = kind + @notation_id = notation + @valid_periods = periods + @volume_only = volume_only + registry.register(self) end - def inherited(klass) - Cycle.cycle_handlers << klass - end + def registry = CycleRegistry.instance private def build_kind_legend legend = {} - Cycle.cycle_handlers.each do |handler| + registry.cycle_classes.each do |handler| # Skip volume_only since it doesn't have a notation_id - next if handler.instance_variable_get(:@volume_only) + next if handler.volume_only? - notation_id = handler.instance_variable_get(:@notation_id) + notation_id = handler.notation_id next unless notation_id legend[notation_id] = { diff --git a/lib/sof/cycle/parser.rb b/lib/sof/cycle/parser.rb index 957e4d5..90a4845 100644 --- a/lib/sof/cycle/parser.rb +++ b/lib/sof/cycle/parser.rb @@ -14,20 +14,29 @@ class Cycle class Parser extend Forwardable - PARTS_REGEX = / - ^(?V(?\d*))? # optional volume - (?(?LE|L|C|W|E|I) # kind - (?\d+) # period count - (?D|W|M|Q|Y)?)? # period_key - (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from - /ix - - # Deliberately a method, not a constant: handlers register themselves - # through Cycle.inherited as each file in sof/cycles loads, which happens - # after this file. A constant would capture the empty set and leave every - # dormant-capable kind unrecognized. + # Built from the registry rather than written out, so an application + # that declares its own kind has its notation recognised too. Rebuilt + # whenever a class registers; cached in between, since parsing is hot. + def self.parts_regex + pattern = Cycle.registry.notation_pattern + return @parts_regex if @parts_regex && @parts_regex_pattern == pattern + + @parts_regex_pattern = pattern + @parts_regex = / + ^(?V(?\d*))? # optional volume + (?(?#{pattern}) # kind + (?\d+) # period count + (?D|W|M|Q|Y)?)? # period_key + (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from + /ix + end + + # Deliberately a method, not a constant: handlers register as each file + # in sof/cycles loads, which happens after this file. A constant would + # capture the empty set and leave every dormant-capable kind + # unrecognized. def self.dormant_capable_kinds - Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact.freeze + Cycle.registry.cycle_classes.select(&:dormant_capable?).map(&:notation_id).compact.freeze end def self.for(notation_or_parser) @@ -48,7 +57,7 @@ def self.load(hash) def initialize(notation) @notation = notation&.upcase - @match = @notation&.match(PARTS_REGEX) + @match = @notation&.match(self.class.parts_regex) @time_span = nil end diff --git a/lib/sof/cycle_registry.rb b/lib/sof/cycle_registry.rb new file mode 100644 index 0000000..39cc69b --- /dev/null +++ b/lib/sof/cycle_registry.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module SOF + # The set of classes that handle cycle kinds. + # + # Registration is explicit — a class joins by declaring what it handles + # (see Cycle.handles), not by subclassing. Inheriting from Cycle for any + # other reason, such as a test double or an abstract intermediate, then + # carries no hidden side effect. + # + # The registry is also what the parser reads to recognise notations, so an + # application can add a kind of its own and have its notation parse without + # reopening this gem. + class CycleRegistry + def self.instance = @instance ||= new + + def initialize + @cycle_classes = [] + @notation_pattern = nil + end + + # Add a handler, displacing any already handling the same kind or + # notation id. That is what lets an application override a built-in + # kind — declare a class handling :lookback and it takes over "L" — + # as well as add one of its own. Re-registering the same class is a + # no-op, so a reloaded file accumulates no duplicates. + # + # Returns the class, so a declaration can use the result. + def register(cycle_class) + displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) } + .select { |klass| conflicts?(klass, cycle_class) } + @cycle_classes -= displaced + @cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class) + @notation_pattern = nil + cycle_class + end + + # Drop a handler. Mainly for an application undoing an override, and for + # tests that register a throwaway kind. + def unregister(cycle_class) + @cycle_classes.delete(cycle_class) + @notation_pattern = nil + cycle_class + end + + def cycle_classes = @cycle_classes.dup + + # The class declaring `kind`. + def handling(kind) + @cycle_classes.find { |klass| klass.handles?(kind) } || + raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle") + end + + # The class declaring `notation_id` — the letter(s) that open a cycle's + # notation, such as "L" or "LE". + def for_notation_id(notation_id) + @cycle_classes.find { |klass| klass.notation_id == notation_id } || + raise(Cycle::InvalidKind, "'#{notation_id}' is not a valid kind of Cycle") + end + + # Registered notation ids, longest first. A volume-only cycle has none, + # so it contributes nothing to parse. + def notation_ids + @cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] } + end + + # The alternation the parser splices into its pattern. Rebuilt whenever a + # class registers, so a kind added at runtime is recognised from then on. + def notation_pattern + @notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|") + end + + private + + # Two handlers conflict when they answer to the same kind, or claim the + # same notation id. A nil notation id claims nothing. + def conflicts?(existing, incoming) + return true if existing.kind == incoming.kind + return false if incoming.notation_id.nil? + + existing.notation_id == incoming.notation_id + end + end +end diff --git a/lib/sof/cycles/calendar.rb b/lib/sof/cycles/calendar.rb index 0c8d325..f5c5ffb 100644 --- a/lib/sof/cycles/calendar.rb +++ b/lib/sof/cycles/calendar.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Calendar < Cycle - @volume_only = false - @notation_id = "C" - @kind = :calendar - @valid_periods = %w[M Q Y] + handles :calendar, notation: "C", periods: %w[M Q Y] class << self def frame_of_reference = "total" diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index 8cd64fb..6283818 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -11,10 +11,7 @@ module SOF module Cycles class EndOf < Cycle - @volume_only = false - @notation_id = "E" - @kind = :end_of - @valid_periods = %w[W M Q Y] + handles :end_of, notation: "E", periods: %w[W M Q Y] def self.recurring? = true diff --git a/lib/sof/cycles/interval.rb b/lib/sof/cycles/interval.rb index 9d56cf1..26a693a 100644 --- a/lib/sof/cycles/interval.rb +++ b/lib/sof/cycles/interval.rb @@ -11,10 +11,7 @@ module SOF module Cycles class Interval < Cycle - @volume_only = false - @notation_id = "I" - @kind = :interval - @valid_periods = %w[D W M Y] + handles :interval, notation: "I", periods: %w[D W M Y] def self.recurring? = true diff --git a/lib/sof/cycles/lookback.rb b/lib/sof/cycles/lookback.rb index e02cf6b..35293ab 100644 --- a/lib/sof/cycles/lookback.rb +++ b/lib/sof/cycles/lookback.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Lookback < Cycle - @volume_only = false - @notation_id = "L" - @kind = :lookback - @valid_periods = %w[D W M Y] + handles :lookback, notation: "L", periods: %w[D W M Y] def self.recurring? = true diff --git a/lib/sof/cycles/lookback_end_of.rb b/lib/sof/cycles/lookback_end_of.rb index 3727536..b7c3b60 100644 --- a/lib/sof/cycles/lookback_end_of.rb +++ b/lib/sof/cycles/lookback_end_of.rb @@ -3,10 +3,7 @@ module SOF module Cycles class LookbackEndOf < Cycle - @volume_only = false - @notation_id = "LE" - @kind = :lookback_end_of - @valid_periods = %w[D W M Q Y] + handles :lookback_end_of, notation: "LE", periods: %w[D W M Q Y] def self.recurring? = true diff --git a/lib/sof/cycles/volume_only.rb b/lib/sof/cycles/volume_only.rb index 4cb5b3a..6a657ec 100644 --- a/lib/sof/cycles/volume_only.rb +++ b/lib/sof/cycles/volume_only.rb @@ -3,10 +3,7 @@ module SOF module Cycles class VolumeOnly < Cycle - @volume_only = true - @notation_id = nil - @kind = :volume_only - @valid_periods = [] + handles :volume_only, volume_only: true class << self def handles?(sym) = sym.nil? || sym.to_s == "volume_only" diff --git a/lib/sof/cycles/within.rb b/lib/sof/cycles/within.rb index 325c996..50b49d2 100644 --- a/lib/sof/cycles/within.rb +++ b/lib/sof/cycles/within.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Within < Cycle - @volume_only = false - @notation_id = "W" - @kind = :within - @valid_periods = %w[D W M Y] + handles :within, notation: "W", periods: %w[D W M Y] def self.recurring? = false diff --git a/spec/sof/custom_cycle_kind_spec.rb b/spec/sof/custom_cycle_kind_spec.rb new file mode 100644 index 0000000..c8f3885 --- /dev/null +++ b/spec/sof/custom_cycle_kind_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + # What an application gets: declare a class, and its notation parses. No + # reopening of this gem, no patching of the parser's pattern. + RSpec.describe "declaring a cycle kind from an application", type: :value do + # Registration is global, so every example puts the registry back. + around do |example| + before = Cycle.registry.cycle_classes + example.run + (Cycle.registry.cycle_classes - before).each { Cycle.registry.unregister(it) } + before.each { Cycle.registry.register(it) } + end + + describe "adding a kind of its own" do + let!(:fortnightly) do + Class.new(Cycle) do + handles :fortnightly, notation: "X", periods: %w[D W M] + + def self.name = "Fortnightly" + def self.recurring? = true + def self.description = "Fortnightly" + def self.examples = ["V1X2W"] + end + end + + it "parses its notation" do + expect(Cycle.for("V1X2W")).to be_a(fortnightly) + end + + it "reads the volume and period from the notation" do + cycle = Cycle.for("V3X2W") + + expect(cycle.volume).to eq(3) + expect(cycle.kind).to eq(:fortnightly) + expect(cycle.period_count).to eq(2) + end + + it "is reachable by kind and by notation id" do + expect(Cycle.class_for_kind(:fortnightly)).to eq(fortnightly) + expect(Cycle.class_for_notation_id("X")).to eq(fortnightly) + end + + it "validates against the periods it declared" do + expect { Cycle.for("V1X2Q") }.to raise_error(Cycle::InvalidPeriod) + end + + it "appears in the legend" do + expect(Cycle.legend["kind"]).to include("X") + end + + it "leaves the built-in kinds working" do + expect(Cycle.for("V1L30D")).to be_a(Cycles::Lookback) + expect(Cycle.for("V1LE6M")).to be_a(Cycles::LookbackEndOf) + end + end + + describe "overriding a built-in kind" do + let!(:custom_lookback) do + Class.new(Cycles::Lookback) do + handles :lookback, notation: "L", periods: %w[D W M Y] + + def self.name = "CustomLookback" + def self.description = "Custom" + def self.examples = ["V1L30D"] + def to_s = "custom lookback" + end + end + + it "takes over the notation from the built-in class" do + expect(Cycle.for("V1L30D")).to be_a(custom_lookback) + expect(Cycle.for("V1L30D").to_s).to eq("custom lookback") + end + + it "displaces the built-in rather than sitting alongside it" do + expect(Cycle.registry.cycle_classes).not_to include(Cycles::Lookback) + end + + it "does not disturb the kind whose notation merely shares a prefix" do + expect(Cycle.for("V1LE6M")).to be_a(Cycles::LookbackEndOf) + end + end + + # The parser compiles its pattern once and reuses it, so a kind declared + # after parsing has already begun — an initializer running after first + # use — only works if that cache is keyed to what is registered. + describe "declaring a kind after parsing has already happened" do + it "recognises the new notation anyway" do + Cycle.for("V1L30D") # compile the pattern with only the built-ins + + Class.new(Cycle) do + handles :late, notation: "Z", periods: %w[D] + def self.name = "Late" + def self.recurring? = true + end + + expect(Cycle.for("V1Z5D").kind).to eq(:late) + end + + it "stops recognising a notation once its class is unregistered" do + late = Class.new(Cycle) do + handles :late, notation: "Z", periods: %w[D] + def self.name = "Late" + def self.recurring? = true + end + expect(Cycle.for("V1Z5D").kind).to eq(:late) + + Cycle.registry.unregister(late) + + expect { Cycle.for("V1Z5D") }.to raise_error(Cycle::InvalidInput) + end + end + + describe "subclassing without declaring" do + it "registers nothing, so an abstract subclass is not a handler" do + abstract = Class.new(Cycle) { def self.name = "Abstract" } + + expect(Cycle.registry.cycle_classes).not_to include(abstract) + end + end + end +end diff --git a/spec/sof/cycle_registry_spec.rb b/spec/sof/cycle_registry_spec.rb new file mode 100644 index 0000000..0983d39 --- /dev/null +++ b/spec/sof/cycle_registry_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe CycleRegistry do + subject(:registry) { described_class.new } + + # A stand-in handler. Registration takes anything answering the three + # questions the registry asks, so specs need no real Cycle subclass. + def handler(kind:, notation_id: nil) + Class.new do + define_singleton_method(:kind) { kind } + define_singleton_method(:notation_id) { notation_id } + define_singleton_method(:handles?) { |sym| kind.to_s == sym.to_s } + define_singleton_method(:inspect) { "handler(#{kind})" } + end + end + + describe "#register" do + it "adds a class and reports it as registered" do + klass = handler(kind: :lookback, notation_id: "L") + + registry.register(klass) + + expect(registry.cycle_classes).to include(klass) + end + + it "is idempotent — registering twice keeps one entry" do + klass = handler(kind: :lookback, notation_id: "L") + + 2.times { registry.register(klass) } + + expect(registry.cycle_classes.count(klass)).to eq(1) + end + + it "returns the registered class so a declaration can chain" do + klass = handler(kind: :lookback, notation_id: "L") + + expect(registry.register(klass)).to eq(klass) + end + end + + describe "#handling" do + it "finds the class declaring that kind" do + lookback = registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :calendar, notation_id: "C")) + + expect(registry.handling(:lookback)).to eq(lookback) + end + + it "matches a string kind as well as a symbol" do + lookback = registry.register(handler(kind: :lookback, notation_id: "L")) + + expect(registry.handling("lookback")).to eq(lookback) + end + + it "raises InvalidKind for an unknown kind" do + expect { registry.handling(:nonsense) } + .to raise_error(Cycle::InvalidKind, /nonsense/) + end + end + + describe "#for_notation_id" do + it "finds the class declaring that notation id" do + lookback_end_of = registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + registry.register(handler(kind: :lookback, notation_id: "L")) + + expect(registry.for_notation_id("LE")).to eq(lookback_end_of) + end + + it "raises InvalidKind for an unknown notation id" do + expect { registry.for_notation_id("Z") } + .to raise_error(Cycle::InvalidKind, /Z/) + end + end + + describe "#notation_ids" do + it "omits classes with no notation id, such as volume-only" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :volume_only, notation_id: nil)) + + expect(registry.notation_ids).to eq(["L"]) + end + + it "orders longest first so a longer id is never shadowed by its prefix" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + + expect(registry.notation_ids).to eq(%w[LE L]) + end + end + + describe "#notation_pattern" do + it "builds an alternation of the registered notation ids" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + + expect(registry.notation_pattern).to eq("LE|L") + end + + it "escapes ids so a regex-significant character cannot alter the pattern" do + registry.register(handler(kind: :odd, notation_id: "A.B")) + + expect(registry.notation_pattern).to eq("A\\.B") + end + + it "picks up a class registered after the pattern was first built" do + registry.register(handler(kind: :lookback, notation_id: "L")) + expect(registry.notation_pattern).to eq("L") + + registry.register(handler(kind: :custom, notation_id: "X")) + + expect(registry.notation_pattern).to eq("L|X") + end + end + end +end From 3c41c8fbda046c52cc358b0da2e11ff5093223b3 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Mon, 27 Jul 2026 14:45:54 -0400 Subject: [PATCH 2/3] Register time periods explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatePeriod tracked its subclasses through an inherited hook, the same pattern Cycle had, and the parser's period alternation was written out as D|W|M|Q|Y. Periods now declare what they measure: measures :month, code: "M", interval: "months" which registers them, and the parser builds the period alternation from the registry alongside the kind alternation. Subclassing DatePeriod on its own registers nothing. Unlike cycle kinds, periods are not an extension point: DatePeriod is a private constant, so an application cannot name it to subclass it. The registry is private for the same reason, reached through the public TimeSpan.period_registry — which is what the parser uses, rather than reaching into DatePeriod for it. Also moves the cycle registry from SOF::CycleRegistry to SOF::Cycle::Registry. It was introduced a commit ago as a third constant directly under SOF, which undid part of what restricting the namespace set out to do. Added: `DatePeriod.measures` declares and registers the period a class measures Changed: period classes register by declaring rather than by subclassing; the parser's period alternation is built from the registry Changed: `SOF::CycleRegistry` is now `SOF::Cycle::Registry` --- lib/sof/cycle.rb | 16 +- lib/sof/cycle/parser.rb | 7 +- lib/sof/cycle/registry.rb | 86 +++++++++++ lib/sof/cycle/time_span.rb | 145 ++++++++++++++---- lib/sof/cycle_registry.rb | 84 ---------- .../registry_spec.rb} | 2 +- 6 files changed, 209 insertions(+), 131 deletions(-) create mode 100644 lib/sof/cycle/registry.rb delete mode 100644 lib/sof/cycle_registry.rb rename spec/sof/{cycle_registry_spec.rb => cycle/registry_spec.rb} (99%) diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index b4c9ae6..611cba0 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "forwardable" -require_relative "cycle_registry" +require_relative "cycle/registry" require_relative "cycle/parser" module SOF @@ -174,7 +174,7 @@ def handles(kind, notation: nil, periods: [], volume_only: false) registry.register(self) end - def registry = CycleRegistry.instance + def registry = Registry.instance private @@ -197,14 +197,10 @@ def build_kind_legend def build_period_legend legend = {} - # Use known period codes since DatePeriod is private - period_mappings = { - "D" => "day", - "W" => "week", - "M" => "month", - "Q" => "quarter", - "Y" => "year" - } + # Built from the registered periods, so one an application adds shows + # up here too. + period_mappings = TimeSpan.period_registry.period_classes + .to_h { |klass| [klass.code, klass.period.to_s] } period_mappings.each do |code, period_name| legend[code] = { diff --git a/lib/sof/cycle/parser.rb b/lib/sof/cycle/parser.rb index 90a4845..c815e8f 100644 --- a/lib/sof/cycle/parser.rb +++ b/lib/sof/cycle/parser.rb @@ -18,15 +18,16 @@ class Parser # that declares its own kind has its notation recognised too. Rebuilt # whenever a class registers; cached in between, since parsing is hot. def self.parts_regex - pattern = Cycle.registry.notation_pattern + pattern = [Cycle.registry.notation_pattern, Cycle::TimeSpan.period_registry.code_pattern] return @parts_regex if @parts_regex && @parts_regex_pattern == pattern @parts_regex_pattern = pattern + kinds, periods = pattern @parts_regex = / ^(?V(?\d*))? # optional volume - (?(?#{pattern}) # kind + (?(?#{kinds}) # kind (?\d+) # period count - (?D|W|M|Q|Y)?)? # period_key + (?#{periods})?)? # period_key (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from /ix end diff --git a/lib/sof/cycle/registry.rb b/lib/sof/cycle/registry.rb new file mode 100644 index 0000000..21817a0 --- /dev/null +++ b/lib/sof/cycle/registry.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module SOF + class Cycle + # The set of classes that handle cycle kinds. + # + # Registration is explicit — a class joins by declaring what it handles + # (see Cycle.handles), not by subclassing. Inheriting from Cycle for any + # other reason, such as a test double or an abstract intermediate, then + # carries no hidden side effect. + # + # The registry is also what the parser reads to recognise notations, so an + # application can add a kind of its own and have its notation parse without + # reopening this gem. + class Registry + def self.instance = @instance ||= new + + def initialize + @cycle_classes = [] + @notation_pattern = nil + end + + # Add a handler, displacing any already handling the same kind or + # notation id. That is what lets an application override a built-in + # kind — declare a class handling :lookback and it takes over "L" — + # as well as add one of its own. Re-registering the same class is a + # no-op, so a reloaded file accumulates no duplicates. + # + # Returns the class, so a declaration can use the result. + def register(cycle_class) + displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) } + .select { |klass| conflicts?(klass, cycle_class) } + @cycle_classes -= displaced + @cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class) + @notation_pattern = nil + cycle_class + end + + # Drop a handler. Mainly for an application undoing an override, and for + # tests that register a throwaway kind. + def unregister(cycle_class) + @cycle_classes.delete(cycle_class) + @notation_pattern = nil + cycle_class + end + + def cycle_classes = @cycle_classes.dup + + # The class declaring `kind`. + def handling(kind) + @cycle_classes.find { |klass| klass.handles?(kind) } || + raise(InvalidKind, "':#{kind}' is not a valid kind of Cycle") + end + + # The class declaring `notation_id` — the letter(s) that open a cycle's + # notation, such as "L" or "LE". + def for_notation_id(notation_id) + @cycle_classes.find { |klass| klass.notation_id == notation_id } || + raise(InvalidKind, "'#{notation_id}' is not a valid kind of Cycle") + end + + # Registered notation ids, longest first. A volume-only cycle has none, + # so it contributes nothing to parse. + def notation_ids + @cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] } + end + + # The alternation the parser splices into its pattern. Rebuilt whenever a + # class registers, so a kind added at runtime is recognised from then on. + def notation_pattern + @notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|") + end + + private + + # Two handlers conflict when they answer to the same kind, or claim the + # same notation id. A nil notation id claims nothing. + def conflicts?(existing, incoming) + return true if existing.kind == incoming.kind + return false if incoming.notation_id.nil? + + existing.notation_id == incoming.notation_id + end + end + end +end diff --git a/lib/sof/cycle/time_span.rb b/lib/sof/cycle/time_span.rb index bcb2798..cd56e12 100644 --- a/lib/sof/cycle/time_span.rb +++ b/lib/sof/cycle/time_span.rb @@ -16,6 +16,78 @@ class TimeSpan # class InvalidPeriod < StandardError; end + # The set of classes measuring a period of time. Registration is + # explicit — a class joins by declaring what it measures, not by + # subclassing — which is what removes the inherited hook and lets the + # parser build its period alternation from what is registered. + # + # Internal, like DatePeriod itself: periods are not an extension point. + # Reach it through TimeSpan.period_registry. + class PeriodRegistry + def self.instance = @instance ||= new + + def initialize + @period_classes = [] + @code_pattern = nil + end + + # Add a period class, displacing any already measuring the same period + # or claiming the same code, so an application can replace a built-in as + # well as add to it. Returns the class. + def register(period_class) + displaced = @period_classes.reject { |klass| klass.equal?(period_class) } + .select { |klass| conflicts?(klass, period_class) } + @period_classes -= displaced + @period_classes << period_class unless @period_classes.include?(period_class) + @code_pattern = nil + period_class + end + + def unregister(period_class) + @period_classes.delete(period_class) + @code_pattern = nil + period_class + end + + def period_classes = @period_classes.dup + + # The class whose notation code this is, e.g. "M". Case-insensitive, + # since a notation may be written either way. Nil when unrecognised — + # DatePeriod.for falls back to its own default. + def for_code(code) + return if code.nil? + + normalized = code.to_s.upcase + @period_classes.find { |klass| klass.code == normalized } + end + + # The class measuring this period, e.g. :month. Nil when unrecognised. + def for_period(period) + @period_classes.find { |klass| klass.period.to_s == period.to_s } + end + + # Registered codes, longest first so a longer code is never shadowed by + # its own prefix. + def codes + @period_classes.filter_map(&:code).uniq.sort_by { |code| [-code.length, code] } + end + + # The alternation the parser splices in for the period key. Rebuilt + # whenever a class registers. + def code_pattern + @code_pattern ||= codes.map { Regexp.escape(it) }.join("|") + end + + private + + def conflicts?(existing, incoming) + return true if existing.period == incoming.period + return false if incoming.code.nil? + + existing.code == incoming.code + end + end + class << self # Return a time_span for the given count and period def for(count, period) @@ -39,15 +111,15 @@ def notation(hash) ].compact.join end + # The registry of period classes, owned by DatePeriod. Exposed here + # because DatePeriod is a private constant, so callers outside cannot + # name it — the parser needs the code alternation to build its pattern. + def period_registry = PeriodRegistry.instance + # Return the notation character for the given period name def notation_id_from_name(name) - type = DatePeriod.types.find do |klass| - klass.period.to_s == name.to_s - end - - raise InvalidPeriod, "'#{name}' is not a valid period" unless type - - type.code + period_registry.for_period(name)&.code || + raise(InvalidPeriod, "'#{name}' is not a valid period") end end @@ -69,16 +141,33 @@ def for(count, period_notation) @cached_periods[period_notation][count] end - def for_notation(notation) - DatePeriod.types.find do |klass| - klass.code == notation.to_s.upcase - end - end - - def types = @types ||= Set.new - - def inherited(klass) - DatePeriod.types << klass + def for_notation(notation) = registry.for_code(notation) + + def types = registry.period_classes + + def registry = TimeSpan.period_registry + + # Declare the period this class measures, and register it. + # + # As with Cycle.handles, registration follows from declaring, so + # subclassing DatePeriod on its own registers nothing, and an + # application can add a period of its own — its code is recognised + # because the parser builds that alternation from the registry. + # + # @param period [Symbol] the period measured, e.g. :month + # @param code [String] the notation character, e.g. "M" + # @param interval [String] the plural name used in descriptions + # + # @example + # class Fortnight < DatePeriod + # measures :fortnight, code: "N", interval: "fortnights" + # def duration = (count * 2).weeks + # end + def measures(period, code:, interval:) + @period = period + @code = code + @interval = interval + registry.register(self) end @period = nil @@ -115,9 +204,7 @@ def humanized_period end class Year < self - @period = :year - @code = "Y" - @interval = "years" + measures :year, code: "Y", interval: "years" def end_of_period(date) date.end_of_year @@ -129,9 +216,7 @@ def beginning_of_period(date) end class Quarter < self - @period = :quarter - @code = "Q" - @interval = "quarters" + measures :quarter, code: "Q", interval: "quarters" def duration (count * 3).months @@ -147,9 +232,7 @@ def beginning_of_period(date) end class Month < self - @period = :month - @code = "M" - @interval = "months" + measures :month, code: "M", interval: "months" def end_of_period(date) date.end_of_month @@ -161,9 +244,7 @@ def beginning_of_period(date) end class Week < self - @period = :week - @code = "W" - @interval = "weeks" + measures :week, code: "W", interval: "weeks" def end_of_period(date) date.end_of_week @@ -175,9 +256,7 @@ def beginning_of_period(date) end class Day < self - @period = :day - @code = "D" - @interval = "days" + measures :day, code: "D", interval: "days" def end_of_period(date) date @@ -188,7 +267,7 @@ def beginning_of_period(date) end end end - private_constant :DatePeriod + private_constant :DatePeriod, :PeriodRegistry def initialize(count, period_id) @count = Integer(count, exception: false) diff --git a/lib/sof/cycle_registry.rb b/lib/sof/cycle_registry.rb deleted file mode 100644 index 39cc69b..0000000 --- a/lib/sof/cycle_registry.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module SOF - # The set of classes that handle cycle kinds. - # - # Registration is explicit — a class joins by declaring what it handles - # (see Cycle.handles), not by subclassing. Inheriting from Cycle for any - # other reason, such as a test double or an abstract intermediate, then - # carries no hidden side effect. - # - # The registry is also what the parser reads to recognise notations, so an - # application can add a kind of its own and have its notation parse without - # reopening this gem. - class CycleRegistry - def self.instance = @instance ||= new - - def initialize - @cycle_classes = [] - @notation_pattern = nil - end - - # Add a handler, displacing any already handling the same kind or - # notation id. That is what lets an application override a built-in - # kind — declare a class handling :lookback and it takes over "L" — - # as well as add one of its own. Re-registering the same class is a - # no-op, so a reloaded file accumulates no duplicates. - # - # Returns the class, so a declaration can use the result. - def register(cycle_class) - displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) } - .select { |klass| conflicts?(klass, cycle_class) } - @cycle_classes -= displaced - @cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class) - @notation_pattern = nil - cycle_class - end - - # Drop a handler. Mainly for an application undoing an override, and for - # tests that register a throwaway kind. - def unregister(cycle_class) - @cycle_classes.delete(cycle_class) - @notation_pattern = nil - cycle_class - end - - def cycle_classes = @cycle_classes.dup - - # The class declaring `kind`. - def handling(kind) - @cycle_classes.find { |klass| klass.handles?(kind) } || - raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle") - end - - # The class declaring `notation_id` — the letter(s) that open a cycle's - # notation, such as "L" or "LE". - def for_notation_id(notation_id) - @cycle_classes.find { |klass| klass.notation_id == notation_id } || - raise(Cycle::InvalidKind, "'#{notation_id}' is not a valid kind of Cycle") - end - - # Registered notation ids, longest first. A volume-only cycle has none, - # so it contributes nothing to parse. - def notation_ids - @cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] } - end - - # The alternation the parser splices into its pattern. Rebuilt whenever a - # class registers, so a kind added at runtime is recognised from then on. - def notation_pattern - @notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|") - end - - private - - # Two handlers conflict when they answer to the same kind, or claim the - # same notation id. A nil notation id claims nothing. - def conflicts?(existing, incoming) - return true if existing.kind == incoming.kind - return false if incoming.notation_id.nil? - - existing.notation_id == incoming.notation_id - end - end -end diff --git a/spec/sof/cycle_registry_spec.rb b/spec/sof/cycle/registry_spec.rb similarity index 99% rename from spec/sof/cycle_registry_spec.rb rename to spec/sof/cycle/registry_spec.rb index 0983d39..9c6f281 100644 --- a/spec/sof/cycle_registry_spec.rb +++ b/spec/sof/cycle/registry_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" module SOF - RSpec.describe CycleRegistry do + RSpec.describe Cycle::Registry do subject(:registry) { described_class.new } # A stand-in handler. Registration takes anything answering the three From aa24182a1b2575cf60364c9ca9a94fa36a03aa73 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Mon, 27 Jul 2026 15:16:36 -0400 Subject: [PATCH 3/3] Name the registration entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration never required inheritance — the registry takes any class answering the same questions — but the only way to reach it was Cycle.registry.register, which reads as internal plumbing and documents nothing about what a handler must provide. Cycle.register and Cycle.unregister name it, and the contract a non-inheriting handler has to satisfy is written down beside them. Inheriting and declaring with `handles` remains the easier path, since it supplies the behaviour Cycle.for goes on to use. Added: `Cycle.register` and `Cycle.unregister` register a handler that need not inherit from Cycle --- lib/sof/cycle.rb | 31 +++++++++++++++++++++- spec/sof/custom_cycle_kind_spec.rb | 42 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 611cba0..dbc0664 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -171,9 +171,38 @@ def handles(kind, notation: nil, periods: [], volume_only: false) @notation_id = notation @valid_periods = periods @volume_only = volume_only - registry.register(self) + register(self) end + # Register a handler that does not inherit from Cycle. Inheriting and + # declaring with `handles` is the easier path, because it supplies the + # behaviour Cycle.for goes on to use; this is for a class that would + # rather implement that itself. + # + # The class must answer, at the class level: + # + # kind the kind it handles, e.g. :lookback + # notation_id the notation opening it, e.g. "L", or nil + # valid_periods period keys it accepts, e.g. %w[D W M Y] + # volume_only? whether it carries volume alone + # handles?(kind) whether it answers to a kind + # recurring? whether the window repeats + # dormant_capable? whether it can be written without a from date + # validate_period(key) + # description, examples (for the legend) + # + # and instances are built as `new(notation, parser:)`. + # + # Registering displaces any handler already answering to the same kind + # or notation id, so this replaces a built-in as readily as it adds one. + # + # @param cycle_class [Class] the handler to register + # @return [Class] the class registered + def register(cycle_class) = registry.register(cycle_class) + + # Drop a handler, undoing `register` or a `handles` declaration. + def unregister(cycle_class) = registry.unregister(cycle_class) + def registry = Registry.instance private diff --git a/spec/sof/custom_cycle_kind_spec.rb b/spec/sof/custom_cycle_kind_spec.rb index c8f3885..29b14c8 100644 --- a/spec/sof/custom_cycle_kind_spec.rb +++ b/spec/sof/custom_cycle_kind_spec.rb @@ -113,6 +113,48 @@ def self.recurring? = true end end + # Inheriting is the easy path, not a requirement: Cycle.for asks the + # registry for a class and builds it, so anything answering the same + # questions can stand in. + describe "registering a class that does not inherit from Cycle" do + let!(:standalone) do + Class.new do + def self.name = "Standalone" + def self.kind = :standalone + def self.notation_id = "S" + def self.valid_periods = %w[D W M] + def self.volume_only? = false + def self.handles?(sym) = sym.to_s == "standalone" + def self.dormant_capable? = false + def self.recurring? = true + def self.description = "Standalone" + def self.examples = ["V1S3D"] + def self.validate_period(_) = nil + + def initialize(notation, parser:) + @notation = notation + @parser = parser + end + + def to_s = "standalone cycle" + end.tap { Cycle.register(it) } + end + + it "parses its notation and builds the registered class" do + expect(Cycle.for("V1S3D")).to be_a(standalone) + end + + it "is reachable by kind" do + expect(Cycle.class_for_kind(:standalone)).to eq(standalone) + end + + it "is dropped again by unregister" do + Cycle.unregister(standalone) + + expect { Cycle.for("V1S3D") }.to raise_error(Cycle::InvalidInput) + end + end + describe "subclassing without declaring" do it "registers nothing, so an abstract subclass is not a handler" do abstract = Class.new(Cycle) { def self.name = "Abstract" }