Skip to content
Draft
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
14 changes: 7 additions & 7 deletions TODO.complete/34-plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
**Status:** PARTIAL

## Gaps
- `lib/dcc/plugin/base.rb` does not exist, and there is no
`Dcc.load_plugins` or gem-prefix auto-discovery.
- Nothing consumes the registry. `Dcc::Validate`, `Dcc::Convert` and
`Dcc::Cli` never read `Dcc::Plugin.all`, so a registered plugin has no
effect on validation, conversion or the CLI.
- `spec/dcc/plugin_spec.rb` asserts only that the registry stores and returns
objects.
- There is no gem-prefix auto-discovery. `Dcc.load_plugins` loads plugins by
explicit name; nothing scans installed gems for the `dcc/*` prefix.
- Only validators are wired end to end. `Dcc::Plugin::Base` declares
`register_validator` and nothing else, and neither `Dcc::Convert` nor
`Dcc::Cli` reads `Dcc::Plugin.all`, so a converter or CLI subcommand can
be neither declared nor consumed. `Dcc::Validate` does read the registry,
via `Dcc::Validate::Schematron::Profile`.

## Goal
Registry-based plugin system for custom validators, converters, and CLI commands.
Expand Down
34 changes: 34 additions & 0 deletions lib/dcc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,40 @@ def build(version: 3, &)
Builder.call(version: version, &)
end

# Load plugin gems by name.
#
# A gem name maps to its entry file the usual way: "dcc-audit" loads
# "dcc/audit". A slash-separated path is accepted unchanged.
#
# @param names [Array<String, Symbol>] plugin gem names.
# @raise [Dcc::PluginError] if a plugin's entry file cannot be loaded.
# @return [Array<String>] the entry-file path for each name given.
# A plugin already loaded is left alone and its path still returned.
#
# A plugin whose own `require` fails raises from inside a file we did
# find. Reporting that as a missing entry file points the author at the
# wrong problem, so only the entry path's own LoadError is wrapped.
def load_plugins(*names)
names.flatten.map do |name|
path = plugin_path(name)
require path
path
rescue ::LoadError => e
raise unless e.path == path

raise PluginError, "could not load plugin #{name}: tried " \
"require #{path.inspect} (#{e.message})"
end
end

# "dcc-audit" is a gem name and maps to "dcc/audit". Anything already
# carrying a slash is a require path and is left alone, hyphens included.
def plugin_path(name)
text = name.to_s
text.include?("/") ? text : text.tr("-", "/")
end
private :plugin_path

# Migrate a parsed DCC object from one schema version to another.
# @param dcc [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate]
# @param from [String] source version, e.g. "2.3.0".
Expand Down
3 changes: 3 additions & 0 deletions lib/dcc/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class UnknownVersionError < Error; end
# Raised when the builder DSL is used incorrectly.
class BuilderError < Error; end

# Raised when a plugin gem's entry file cannot be loaded.
class PluginError < Error; end

# Raised when a soft dependency (`xmldsig`, `sinatra`, etc.) is required
# but not installed.
class MissingDependencyError < Error
Expand Down
6 changes: 5 additions & 1 deletion lib/dcc/plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
# without modifying the core codebase (Open/Closed Principle).
#
# @example Register a custom validator
# Dcc::Plugin.register(:validators, MyCustomRule)
# class MyPlugin
# include Dcc::Plugin::Base
# register_validator MyCustomRule
# end
#
# @example List all registered validators
# Dcc::Plugin.all(:validators)
module Dcc
module Plugin
autoload :Registry, "dcc/plugin/registry"
autoload :Base, "dcc/plugin/base"

class << self
# @param category [Symbol] e.g. :validators, :converters, :cli_commands.
Expand Down
64 changes: 64 additions & 0 deletions lib/dcc/plugin/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Dcc
module Plugin
# Included by a plugin class so it can declare what it adds to Dcc.
#
# @example
# class MyPlugin
# include Dcc::Plugin::Base
# register_validator MyRule
# end
module Base
# @param base [Class] the including class.
def self.included(base)
base.extend(ClassMethods)
end

# `Profile#call` does `rule_class.new.check_on(dcc)`, so a validator
# has to be a class. An instance responds to `#check_on` and so looks
# right, but would fail deep inside validation with a message naming
# neither the plugin nor the author's line.
#
# @param entry [Object] the candidate rule.
# @raise [ArgumentError] unless entry is a class defining #check_on.
# @return [Class] the entry.
def self.rule_class!(entry)
return entry if rule_class?(entry)

raise ::ArgumentError,
"expected a rule class responding to #check_on, " \
"got #{describe(entry)}"
end

# @param entry [Object]
# @return [Boolean]
def self.rule_class?(entry)
entry.is_a?(::Class) && entry.method_defined?(:check_on)
end
private_class_method :rule_class?

# @param entry [Object]
# @return [String]
def self.describe(entry)
return entry.inspect if entry.is_a?(::Class)

"an instance of #{entry.class}"
end
private_class_method :describe

# Declaration helpers available on the including class.
module ClassMethods
# Add a Schematron rule to the active validation profile.
#
# @param rule_class [Class] a rule class defining `#check_on(dcc)`.
# @raise [ArgumentError] unless rule_class is such a class.
# @return [Class] the rule class.
def register_validator(rule_class)
checked = ::Dcc::Plugin::Base.rule_class!(rule_class)
::Dcc::Plugin.register(:validators, checked)
end
end
end
end
end
37 changes: 22 additions & 15 deletions lib/dcc/validate/schematron/profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,32 @@ module Schematron
# result.ok? # => true
# result.issues.size # => 0
class Profile
# The rules shipped with the gem, in execution order.
DEFAULT_RULES = [
Rules::UsedMethodsPlacement,
Rules::UsedSoftwarePlacement,
Rules::InfluenceConditionsPlacement,
Rules::SchemaVersionCheck,
Rules::IdRefIdLinking,
Rules::IsoCodeValidation,
Rules::DateRangeCheck,
Rules::ReleaseFormatCheck,
Rules::UncertaintyConsistency,
Rules::UnitFormatCheck,
Rules::NonSiDeclaration,
Rules::LanguageCodeDedup,
Rules::XmlListSpacing,
].freeze
private_constant :DEFAULT_RULES

attr_reader :dcc, :rules

# Plugin validators are read here, not at class-definition time, so
# a plugin loaded after this class still takes effect. Frozen because
# `attr_reader :rules` hands the array straight to callers.
def initialize(dcc)
@dcc = dcc
@rules = [
Rules::UsedMethodsPlacement,
Rules::UsedSoftwarePlacement,
Rules::InfluenceConditionsPlacement,
Rules::SchemaVersionCheck,
Rules::IdRefIdLinking,
Rules::IsoCodeValidation,
Rules::DateRangeCheck,
Rules::ReleaseFormatCheck,
Rules::UncertaintyConsistency,
Rules::UnitFormatCheck,
Rules::NonSiDeclaration,
Rules::LanguageCodeDedup,
Rules::XmlListSpacing,
]
@rules = (DEFAULT_RULES + ::Dcc::Plugin.all(:validators)).freeze
end

# Run all rules and return a `Dcc::Validate::Result`.
Expand Down
9 changes: 8 additions & 1 deletion lib/dcc/validate/schematron/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ def severity
::Dcc::Validate::Severity::ERROR
end

# A plugin can register an anonymous rule class, whose `name` is nil.
# Falling back keeps one such rule from taking the whole run down and
# losing every issue the built-in rules already found.
#
# @return [String] rule code used in `Issue#code`.
def code
"dcc.schematron.#{self.class.name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(
class_name = self.class.name
return "dcc.schematron.anonymous" unless class_name

"dcc.schematron.#{class_name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(
/([a-z\d])([A-Z])/, '\1_\2'
).downcase}"
end
Expand Down
60 changes: 60 additions & 0 deletions spec/dcc/plugin/base_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Dcc::Plugin::Base do
before { Dcc::Plugin.reset! }
after { Dcc::Plugin.reset! }

let(:plugin_class) do
Class.new do
include Dcc::Plugin::Base
end
end

let(:rule) do
Class.new do
def check_on(_dcc)
[]
end
end
end

it "registers a validator through the class-level helper" do
plugin_class.register_validator(rule)
expect(Dcc::Plugin.all(:validators)).to include(rule)
end

it "returns the rule class from register_validator" do
expect(plugin_class.register_validator(rule)).to be(rule)
end

it "rejects an instance where a rule class was expected" do
expect { plugin_class.register_validator(rule.new) }
.to raise_error(ArgumentError, /got an instance of/)
end

it "rejects a class that does not define check_on" do
expect { plugin_class.register_validator(Class.new) }
.to raise_error(ArgumentError, /responding to #check_on/)
end

# Every real rule in the gem subclasses `Rules::Base` and defines no
# `check_on` of its own, so the guard has to see the inherited one.
describe "a rule that defines no check_on of its own" do
let(:subclass) { Class.new(rule) }

it "carries no check_on of its own" do
expect(subclass.instance_methods(false)).not_to include(:check_on)
end

it "inherits check_on from its superclass" do
expect(subclass.instance_method(:check_on).owner).to be(rule)
end

it "is accepted on that inherited method alone" do
plugin_class.register_validator(subclass)
expect(Dcc::Plugin.all(:validators)).to include(subclass)
end
end
end
1 change: 1 addition & 0 deletions spec/dcc/plugin_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def name; "sample"; end

RSpec.describe Dcc::Plugin do
before { described_class.reset! }
after { described_class.reset! }

describe ".register and .all" do
it "stores plugins by category" do
Expand Down
Loading
Loading