Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
21 changes: 21 additions & 0 deletions lib/dcc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ 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.
def load_plugins(*names)
names.flatten.map do |name|
path = name.to_s
path = path.tr("-", "/") unless path.include?("/")
require path
path
rescue ::LoadError => e
Comment thread
HassanAkbar marked this conversation as resolved.
raise PluginError, "could not load plugin #{name}: tried " \
"require #{path.inspect} (#{e.message})"
end
end

# 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
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
52 changes: 52 additions & 0 deletions spec/dcc/validate/schematron_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,55 @@
expect(issues).to be_an(Array)
end
end

# A plugin-supplied rule. Fires on any document carrying a schemaVersion,
# so it is guaranteed to trigger on the valid.xml fixture.
class PluginProbeRule < Dcc::Validate::Schematron::Rules::Base
def check_on(dcc)
return [] unless Dcc::TypeGuards.has_attribute?(dcc, :schema_version)

[issue(severity: :error, message: "plugin rule fired")]
end
end

RSpec.describe Dcc::Validate::Schematron::Profile do
let(:dcc) { Dcc.parse(File.read(fixtures_path("dcclib", "valid.xml"))) }

before do
Dcc::V3.load_all!
Dcc::Plugin.reset!
end

after { Dcc::Plugin.reset! }

it "keeps the built-in rules" do
expect(described_class.new(dcc).rules)
.to include(Dcc::Validate::Schematron::Rules::DateRangeCheck)
end

it "freezes the rule list it exposes" do
expect(described_class.new(dcc).rules).to be_frozen
end

it "omits plugin rules that were never registered" do
expect(described_class.new(dcc).rules).not_to include(PluginProbeRule)
end

it "appends a registered plugin validator" do
Dcc::Plugin.register(:validators, PluginProbeRule)
expect(described_class.new(dcc).rules).to include(PluginProbeRule)
end

it "fires a plugin rule during a real Schematron run" do
Dcc::Plugin.register(:validators, PluginProbeRule)
result = Dcc::Validate::Schematron.call(dcc)
expect(result.issues.map(&:code))
.to include("dcc.schematron.plugin_probe_rule")
end

it "reports no plugin code when nothing is registered" do
result = Dcc::Validate::Schematron.call(dcc)
expect(result.issues.map(&:code))
.not_to include("dcc.schematron.plugin_probe_rule")
end
end
35 changes: 35 additions & 0 deletions spec/dcc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require "spec_helper"

$LOAD_PATH.unshift(fixtures_path("plugins"))

RSpec.describe Dcc do
describe ".parser_for" do
it "returns Dcc::V2 for version 2" do
Expand Down Expand Up @@ -63,4 +65,37 @@
expect(Dcc.io_like?(nil)).to be(false)
end
end

describe ".load_plugins" do
before { Dcc::Plugin.reset! }
after { Dcc::Plugin.reset! }

it "maps a gem name to its entry-file path" do
expect(described_class.load_plugins("dcc-version"))
.to eq(["dcc/version"])
end

it "leaves a slash-separated path alone, hyphens included" do
expect { described_class.load_plugins("dcc/nope-not-real") }
.to raise_error(Dcc::PluginError, %r{require "dcc/nope-not-real"})
end

it "names the plugin when its entry file is missing" do
expect { described_class.load_plugins("dcc-nope-not-real") }
.to raise_error(Dcc::PluginError, /dcc-nope-not-real/)
end

it "names the path it tried when loading fails" do
expect { described_class.load_plugins("dcc-nope-not-real") }
.to raise_error(Dcc::PluginError, %r{dcc/nope/not/real})
end

# Only this example may load the fixture: `require` is idempotent per
# process, so a second one would find nothing registered.
it "requires the plugin so its declarations take effect" do
described_class.load_plugins("dcc-sample_plugin")
expect(Dcc::Plugin.all(:validators))
.to include(DccSamplePlugin::SampleRule)
end
end
end
13 changes: 13 additions & 0 deletions spec/fixtures/plugins/dcc/sample_plugin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

# Loaded by `Dcc.load_plugins("dcc-sample_plugin")` in `spec/dcc_spec.rb`.
# Stands in for a real `dcc-*` plugin gem's entry file.
class DccSamplePlugin
include Dcc::Plugin::Base

# A plugin's own rule, declared as the file loads.
class SampleRule < ::Dcc::Validate::Schematron::Rules::Base
end

register_validator SampleRule
end
Loading