Current section

Files

Jump to
nldoc_validation lib nldoc validation rule.ex
Raw

lib/nldoc/validation/rule.ex

defmodule NLdoc.Validation.Rule do
@moduledoc """
This module provides a macro for defining validation rules for NLdoc spec objects, i.e. documents and their elements.
Such rules check for semantic correctness and accessibility concerns in the spec objects.
Simply `use NLdoc.Validation.Rule` in your module with all details about the validation rule and implement the `valid?/1`
function to determine whether the spec objects that this rule validates, are valid.
You may also choose to override the `validates/0` and `make_finding/1` functions for more granular control over the
validation process.
If your validation rule requires state to be maintained during the validation process, implement an `update_state/2` function
to update the `NLdoc.Validation.State` struct with the necessary information.
Note that the application of and merging of autofixes to a document as a result of validation is currently not implemented.
Example:
defmodule NLdoc.Validation.Rules.HeadingLevelValid do
use NLdoc.Validation.Rule,
severity: NLdoc.Validation.Severity.error(),
rule: "invalid-heading-level",
ruleset: "https://html.spec.whatwg.org/",
ruleset_version: "5",
validates: [NLdoc.Spec.Heading]
@impl true
def valid?(%NLdoc.Spec.Heading{level: level}, _),
do: level >= 1 and level <= 6
end
"""
alias NLdoc.Spec.ValidationFinding
alias NLdoc.Validation.Severity
@callback make_finding(resource_id :: String.t()) :: ValidationFinding.t()
@callback validates() :: [module()]
@callback valid?(NLdoc.Spec.object(), NLdoc.Validation.State.t()) :: boolean()
@callback validate(NLdoc.Spec.object(), NLdoc.Validation.State.t()) ::
NLdoc.Validation.State.t()
@callback update_state(NLdoc.Validation.State.t(), NLdoc.Spec.object()) ::
NLdoc.Validation.State.t()
@type t :: module()
@type opt() ::
{:severity, Severity.t()}
| {:rule, String.t()}
| {:ruleset, String.t()}
| {:ruleset_version, String.t()}
| {:validates, [module()]}
@spec __using__([opt()]) :: Macro.t()
defmacro __using__(opts) do
{severity, _} = opts |> Keyword.get(:severity) |> Code.eval_quoted([], __CALLER__)
rule = opts |> Keyword.get(:rule)
ruleset = opts |> Keyword.get(:ruleset)
ruleset_version = opts |> Keyword.get(:ruleset_version)
{validates, _} = opts |> Keyword.get(:validates) |> Code.eval_quoted([], __CALLER__)
if severity not in Severity.values() do
raise """
Property :severity is required on `use NLdoc.Validation.Rule` and must be one of #{inspect(Severity.values())}, but got #{inspect(severity)}.
"""
end
if not is_binary(rule) do
raise """
Property :rule is required on `use NLdoc.Validation.Rule` and must be a string, but got #{inspect(rule)}.
"""
end
if not is_binary(ruleset) do
raise """
Property :ruleset is required on `use NLdoc.Validation.Rule` and must be a string, but got #{inspect(ruleset)}.
"""
end
if not is_binary(ruleset_version) do
raise """
Property :ruleset_version is required on `use NLdoc.Validation.Rule` and must be a string, but got #{inspect(ruleset_version)}.
"""
end
if not is_list(validates) or validates == [] or
not Enum.all?(validates, &NLdoc.Spec.Schema.schema_module?/1) do
message = """
Property :validates is required on `use NLdoc.Validation.Rule` and must be a non-empty list of NLdoc.Spec.* modules,
but got #{inspect(validates)}.
"""
message =
if not is_list(validates) or validates == [] do
"""
#{message}
The :validates property determines which NLdoc spec objects this rule validates.
If that is not defined or an empty list, then this rule validates nothing.
"""
else
"""
#{message}
Validation rules are only ever applied to NLdoc spec objects, so in order for a rule to actually match those,
:validates must contain valid NLdoc.Spec.* modules.
The following entries are not valid NLdoc.Spec.* modules:
- #{validates |> Enum.reject(&NLdoc.Spec.Schema.schema_module?/1) |> Enum.map_join("\n- ", &inspect/1)}
"""
end
raise message
end
quote do
alias NLdoc.Validation.State
@behaviour NLdoc.Validation.Rule
@impl true
def make_finding(resource_id) when is_binary(resource_id) do
%NLdoc.Spec.ValidationFinding{
type: NLdoc.Spec.Type.ValidationFinding.value(),
resource_id: resource_id,
severity: unquote(severity),
rule: unquote(rule),
ruleset: unquote(ruleset),
ruleset_version: unquote(ruleset_version)
}
end
@impl NLdoc.Validation.Rule
def validates,
do: unquote(validates)
@impl NLdoc.Validation.Rule
def validate(resource, state = %State{}) do
if valid?(resource, state) do
state
|> update_state(resource)
else
state
|> State.prepend(:findings, make_finding(resource.id))
|> update_state(resource)
end
end
# Note: `valid?` and `update_state` are defined here solely for `defoverridable`.
# Their default implementations are defined in the `__before_compile__` macro.
@impl NLdoc.Validation.Rule
def valid?(_resource, _state = %State{}),
do: true
@impl NLdoc.Validation.Rule
def update_state(state = %State{}, resource),
do: state
defoverridable validates: 0, valid?: 2, validate: 2, update_state: 2
@before_compile NLdoc.Validation.Rule
end
end
defmacro __before_compile__(_env) do
quote do
alias NLdoc.Validation.State
@impl NLdoc.Validation.Rule
def valid?(_, _state = %State{}),
do: true
@impl NLdoc.Validation.Rule
def update_state(state = %State{}, _),
do: state
end
end
end