Skip to main content

html_conform/
lib.rs

1//! HTML5 conformance checking with browser-style error recovery.
2//!
3//! `check`/`check_with_options` combine five finding sources, in order:
4//! HTML parser diagnostics, RELAX NG schema (content-model) validation,
5//! Schematron-style assertion (co-constraint) checking,
6//! `<script type="importmap"|"speculationrules">` JSON content validation
7//! (`src/scripts.rs` — a value/content-format check like the RELAX NG
8//! datatypes, just on element text content instead of an attribute), and
9//! `<meta http-equiv="Content-Security-Policy">` enforcement against
10//! inline script/style content elsewhere in the document
11//! (`src/csp_enforcement.rs` — a genuine cross-element check needing a
12//! real CSP source-list parser, not expressible as a `rules/*.sch` rule
13//! or a `w:*` datatype).
14
15mod assertions;
16mod csp_enforcement;
17mod datatypes;
18mod finding;
19mod infoset;
20mod parse;
21mod schema;
22mod scripts;
23
24use assertions::SchematronEngine;
25
26pub use finding::{CheckError, CheckReport, Finding, Severity, SourceLocation};
27
28/// Options that control which diagnostics are included in a check report.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct CheckOptions {
31    /// Include recoverable HTML parser diagnostics in the report.
32    pub include_parse_errors: bool,
33}
34
35impl Default for CheckOptions {
36    fn default() -> Self {
37        Self {
38            include_parse_errors: true,
39        }
40    }
41}
42
43/// Checks a complete HTML document with the default options.
44///
45/// Findings are returned in parser order. Technical initialization failures are
46/// returned as [`CheckError`] rather than being represented as findings.
47pub fn check(html: &str) -> Result<CheckReport, CheckError> {
48    check_with_options(html, CheckOptions::default())
49}
50
51/// Checks a complete HTML document with explicit options.
52///
53/// This function always uses HTML5 error recovery. `include_parse_errors`
54/// controls whether recovered parser diagnostics become report findings.
55/// Schema and assertion findings are always included — the checker only
56/// omits parser diagnostics on request, not the conformance findings that
57/// are the point of running it at all.
58///
59/// # Errors
60///
61/// Only for a genuine setup failure in this checker itself (the embedded
62/// HTML5 schema failed to compile, or the embedded assertion rule set
63/// failed to parse) — never for a document that is merely non-conformant,
64/// which is reported through [`CheckReport::findings`] instead.
65pub fn check_with_options(html: &str, options: CheckOptions) -> Result<CheckReport, CheckError> {
66    let parsed = parse::parse(html);
67    let document = infoset::normalize(parsed.document(), parsed.source());
68
69    let mut findings = if options.include_parse_errors {
70        parse::findings(&parsed)
71    } else {
72        Vec::new()
73    };
74
75    let schema_errors =
76        schema::validate_document(&document).map_err(|message| CheckError::Initialization {
77            message: format!("schema validation setup failed: {message}"),
78        })?;
79    findings.extend(schema::findings(&schema_errors));
80
81    let assertion_failures = assertions::RuleSetEngine
82        .check(&document)
83        .map_err(|error| CheckError::Initialization {
84            message: format!("assertion engine setup failed: {error}"),
85        })?;
86    findings.extend(assertions::findings(&assertion_failures));
87
88    findings.extend(scripts::findings(parsed.document()));
89    findings.extend(csp_enforcement::findings(parsed.document()));
90
91    Ok(CheckReport { findings })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{CheckOptions, SourceLocation, check, check_with_options};
97
98    #[test]
99    fn valid_html_has_no_parser_findings() {
100        let report = check("<!doctype html><title>Example</title><p>Hello</p>")
101            .expect("HTML5 parsing should recover");
102
103        assert!(report.findings.is_empty());
104        assert!(!report.has_errors());
105    }
106
107    #[test]
108    fn parser_diagnostics_can_be_excluded() {
109        // Otherwise fully schema-conformant (has a <title>, per
110        // schema/html5/meta.rnc's required head.inner) so that excluding
111        // the recoverable parser diagnostic (an unknown entity reference)
112        // leaves no findings at all — isolates this test to what it's
113        // actually about (the `include_parse_errors` toggle), rather than
114        // also depending on schema/assertion behavior.
115        let report = check_with_options(
116            "<!doctype html><title>Example</title><p>&notAnEntity;</p>",
117            CheckOptions {
118                include_parse_errors: false,
119            },
120        )
121        .expect("HTML5 parsing should recover");
122
123        assert!(report.findings.is_empty());
124    }
125
126    #[test]
127    fn parser_diagnostics_are_included_by_default() {
128        let report = check("<!doctype html><title>Example</title><p>&notAnEntity;</p>")
129            .expect("HTML5 parsing should recover");
130
131        assert_eq!(report.findings.len(), 1);
132        assert_eq!(report.findings[0].rule_id, "parser.html5");
133    }
134
135    #[test]
136    fn schema_violation_is_reported_as_a_finding() {
137        // No <title> — schema/html5/meta.rnc's head.inner requires one.
138        // Fires against the *synthesized* implicit `<head>` (no explicit
139        // `<head>` tag in this input), so `location` is `None` here — see
140        // `schema_violation_location_is_populated_for_an_explicit_element`
141        // below for the populated case.
142        let report = check_with_options(
143            "<!doctype html><p>Hello</p>",
144            CheckOptions {
145                include_parse_errors: false,
146            },
147        )
148        .expect("HTML5 parsing should recover");
149
150        assert_eq!(report.findings.len(), 1);
151        assert_eq!(report.findings[0].rule_id, "schema.html5");
152        assert_eq!(report.findings[0].location, None);
153        assert!(report.has_errors());
154    }
155
156    #[test]
157    fn schema_violation_location_is_populated_for_an_explicit_element() {
158        // Phase 08: `relax_ng::Element::Location` became generic
159        // (`src/infoset.rs` sets it to `crate::finding::SourceLocation`
160        // directly) — a schema.html5 finding against an *explicit*
161        // element now carries a real, structured position, not `None`.
162        let report = check_with_options(
163            r#"<!doctype html><title>x</title><p bogus="1">hi</p>"#,
164            CheckOptions {
165                include_parse_errors: false,
166            },
167        )
168        .expect("HTML5 parsing should recover");
169
170        assert_eq!(report.findings.len(), 1);
171        assert_eq!(report.findings[0].rule_id, "schema.html5");
172        assert_eq!(
173            report.findings[0].location,
174            Some(SourceLocation {
175                line: 1,
176                column: 32,
177                byte_offset: 31,
178            })
179        );
180    }
181
182    #[test]
183    fn assertion_violation_is_reported_as_a_finding() {
184        let report = check_with_options(
185            r#"<!doctype html><title>Example</title><div aria-hidden="true" tabindex="0">x</div>"#,
186            CheckOptions {
187                include_parse_errors: false,
188            },
189        )
190        .expect("HTML5 parsing should recover");
191
192        assert_eq!(report.findings.len(), 1);
193        assert_eq!(
194            report.findings[0].rule_id,
195            "assertion.aria.hidden-not-focusable"
196        );
197    }
198}