Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 72 additions & 29 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "forwardable"
require_relative "cycle/registry"
require_relative "cycle/parser"

module SOF
Expand Down Expand Up @@ -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:)
Expand All @@ -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
#
Expand All @@ -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
Expand Down Expand Up @@ -150,23 +143,77 @@ 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<String>] 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
register(self)
end

def inherited(klass)
Cycle.cycle_handlers << klass
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

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] = {
Expand All @@ -179,14 +226,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] = {
Expand Down
38 changes: 24 additions & 14 deletions lib/sof/cycle/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,30 @@ class Cycle
class Parser
extend Forwardable

PARTS_REGEX = /
^(?<vol>V(?<volume>\d*))? # optional volume
(?<set>(?<kind>LE|L|C|W|E|I) # kind
(?<period_count>\d+) # period count
(?<period_key>D|W|M|Q|Y)?)? # period_key
(?<from>F(?<from_date>\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, 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 = /
^(?<vol>V(?<volume>\d*))? # optional volume
(?<set>(?<kind>#{kinds}) # kind
(?<period_count>\d+) # period count
(?<period_key>#{periods})?)? # period_key
(?<from>F(?<from_date>\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)
Expand All @@ -48,7 +58,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

Expand Down
86 changes: 86 additions & 0 deletions lib/sof/cycle/registry.rb
Original file line number Diff line number Diff line change
@@ -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
Loading