Skip to main content

html_conform/
lib.rs

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