use std::error::Error;
use std::fmt;
use std::sync::LazyLock;
use crate::finding::{Finding, Severity};
use crate::infoset::NormalizedHtmlDocument;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AssertionFailure {
pub(crate) rule_id: String,
pub(crate) severity: Severity,
pub(crate) message: String,
pub(crate) location: Option<crate::finding::SourceLocation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EngineError(String);
impl fmt::Display for EngineError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "assertion engine error: {}", self.0)
}
}
impl Error for EngineError {}
pub(crate) trait SchematronEngine {
fn check(
&self,
document: &NormalizedHtmlDocument,
) -> Result<Vec<AssertionFailure>, EngineError>;
}
fn severity_from_role(role: Option<&str>) -> Severity {
match role {
Some("warning") => Severity::Warning,
Some("info") => Severity::Info,
_ => Severity::Error,
}
}
const RULE_FILES: &[(&str, &str)] = &[
("aria.sch", include_str!("../rules/aria.sch")),
("tables.sch", include_str!("../rules/tables.sch")),
(
"obsolete-elements.sch",
include_str!("../rules/obsolete-elements.sch"),
),
("ids.sch", include_str!("../rules/ids.sch")),
("microdata.sch", include_str!("../rules/microdata.sch")),
("headings.sch", include_str!("../rules/headings.sch")),
("roles.sch", include_str!("../rules/roles.sch")),
("elements.sch", include_str!("../rules/elements.sch")),
(
"aria-constraints.sch",
include_str!("../rules/aria-constraints.sch"),
),
(
"aria-html-restrictions.sch",
include_str!("../rules/aria-html-restrictions.sch"),
),
("attributes.sch", include_str!("../rules/attributes.sch")),
];
fn parse_rule_files() -> Result<Vec<schematron_engine::Schema>, EngineError> {
RULE_FILES
.iter()
.map(|(name, xml)| {
schematron_engine::parse(xml)
.map_err(|error| EngineError(format!("rules/{name}: {error}")))
})
.collect()
}
static RULE_SCHEMAS: LazyLock<Result<Vec<schematron_engine::Schema>, EngineError>> =
LazyLock::new(parse_rule_files);
pub(crate) struct RuleSetEngine;
impl SchematronEngine for RuleSetEngine {
fn check(
&self,
document: &NormalizedHtmlDocument,
) -> Result<Vec<AssertionFailure>, EngineError> {
let schemas = RULE_SCHEMAS.as_ref().map_err(Clone::clone)?;
let mut failures = Vec::new();
for schema in schemas {
let reports = schematron_engine::evaluate(schema, document)
.map_err(|error| EngineError(error.to_string()))?;
for report in reports {
let Some(rule_id) = report.check_id else {
return Err(EngineError(format!(
"fired check in pattern {:?} has no @id — every rules/*.sch assert/report must declare one",
report.pattern_id
)));
};
failures.push(AssertionFailure {
rule_id,
severity: severity_from_role(report.role.as_deref()),
message: report.message,
location: report.node.position().copied(),
});
}
}
Ok(failures)
}
}
pub(crate) fn findings(failures: &[AssertionFailure]) -> Vec<Finding> {
failures
.iter()
.map(|failure| Finding {
rule_id: format!("assertion.{}", failure.rule_id),
severity: failure.severity,
message: failure.message.clone(),
location: failure.location,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{AssertionFailure, EngineError, RuleSetEngine, SchematronEngine, findings};
use crate::finding::Severity;
use crate::infoset::normalize;
use crate::parse::parse;
fn check_html(html: &str) -> Vec<AssertionFailure> {
let parsed = parse(html);
let document = normalize(parsed.document(), parsed.source());
RuleSetEngine
.check(&document)
.expect("rule set should evaluate without an engine error")
}
#[test]
fn all_embedded_rule_files_parse() {
super::parse_rule_files().expect("every embedded rule file should parse");
}
#[test]
fn aria_hidden_with_tabindex_fires() {
let failures = check_html(r#"<div aria-hidden="true" tabindex="0">x</div>"#);
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].rule_id, "aria.hidden-not-focusable");
assert_eq!(failures[0].severity, Severity::Error);
}
#[test]
fn aria_hidden_without_tabindex_is_clean() {
let failures = check_html(r#"<div aria-hidden="true">x</div>"#);
assert!(failures.is_empty());
}
#[test]
fn th_scope_enum_valid_and_invalid() {
assert!(check_html(r#"<table><tr><th scope="col">A</th></tr></table>"#).is_empty());
let failures = check_html(r#"<table><tr><th scope="column">A</th></tr></table>"#);
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].rule_id, "tables.th-scope-enum");
}
#[test]
fn obsolete_elements_fire_and_ordinary_elements_dont() {
let failures = check_html("<font>x</font><center>y</center>");
assert_eq!(failures.len(), 2);
assert!(
failures
.iter()
.all(|f| f.rule_id == "obsolete-elements.deprecated")
);
assert!(check_html("<p>ordinary</p>").is_empty());
}
#[test]
fn multiple_rule_files_fire_independently_in_one_document() {
let failures = check_html(
r#"<div aria-hidden="true" tabindex="0">x</div><table><tr><th scope="column">A</th></tr></table><font>x</font>"#,
);
let rule_ids: Vec<&str> = failures.iter().map(|f| f.rule_id.as_str()).collect();
assert!(rule_ids.contains(&"aria.hidden-not-focusable"));
assert!(rule_ids.contains(&"tables.th-scope-enum"));
assert!(rule_ids.contains(&"obsolete-elements.deprecated"));
}
#[test]
fn findings_prefixes_rule_id_and_carries_severity_and_message() {
let failures = vec![AssertionFailure {
rule_id: "aria.hidden-not-focusable".to_owned(),
severity: Severity::Error,
message: "boom".to_owned(),
location: None,
}];
let mapped = findings(&failures);
assert_eq!(mapped.len(), 1);
assert_eq!(mapped[0].rule_id, "assertion.aria.hidden-not-focusable");
assert_eq!(mapped[0].severity, Severity::Error);
assert_eq!(mapped[0].message, "boom");
assert_eq!(mapped[0].location, None);
}
struct MockEngine {
result: Result<Vec<AssertionFailure>, EngineError>,
}
impl SchematronEngine for MockEngine {
fn check(
&self,
_document: &crate::infoset::NormalizedHtmlDocument,
) -> Result<Vec<AssertionFailure>, EngineError> {
self.result.clone()
}
}
#[test]
fn schematron_engine_trait_is_swappable_for_a_mock_implementation() {
let parsed = parse("<p>irrelevant — the mock ignores the document</p>");
let document = normalize(parsed.document(), parsed.source());
let canned = vec![AssertionFailure {
rule_id: "mock.rule".to_owned(),
severity: Severity::Warning,
message: "mock failure".to_owned(),
location: None,
}];
let engine = MockEngine {
result: Ok(canned.clone()),
};
assert_eq!(engine.check(&document).unwrap(), canned);
let failing_engine = MockEngine {
result: Err(EngineError("mock engine error".to_owned())),
};
assert!(failing_engine.check(&document).is_err());
}
}