use html5_parser::{Document, NodeKind, ParseError};
use crate::{Finding, Severity, SourceLocation};
pub(crate) struct ParsedHtml {
document: Document,
source: String,
diagnostics: Vec<ParseError>,
}
impl ParsedHtml {
pub(crate) fn document(&self) -> &Document {
&self.document
}
pub(crate) fn source(&self) -> &str {
&self.source
}
pub(crate) fn diagnostics(&self) -> &[ParseError] {
&self.diagnostics
}
}
pub(crate) fn parse(html: &str) -> ParsedHtml {
let result = html5_parser::parse(html);
ParsedHtml {
document: result.document,
source: html.to_owned(),
diagnostics: result.errors,
}
}
pub(crate) fn findings(parsed: &ParsedHtml) -> Vec<Finding> {
parsed
.diagnostics()
.iter()
.map(|diagnostic| Finding {
rule_id: "parser.html5".to_owned(),
severity: Severity::Error,
message: diagnostic.kind.to_string(),
location: Some(SourceLocation {
line: diagnostic.position.line,
column: diagnostic.position.column,
byte_offset: diagnostic.position.byte_offset,
}),
})
.chain(doctype_findings(parsed.document()))
.chain(charset_after_1024_findings(parsed.document()))
.collect()
}
fn doctype_findings(document: &Document) -> Vec<Finding> {
let doctype = document.children(document.root()).find_map(|id| {
let NodeKind::Doctype {
name,
public_identifier,
system_identifier,
} = &document.node(id).kind
else {
return None;
};
Some((id, name.as_deref(), public_identifier, system_identifier))
});
let Some((id, name, public_identifier, system_identifier)) = doctype else {
return vec![Finding {
rule_id: "parser.html5".to_owned(),
severity: Severity::Error,
message: "start tag seen without seeing a doctype first, expected \
“<!DOCTYPE html>”"
.to_owned(),
location: None,
}];
};
let is_public_id_missing = public_identifier.as_deref().is_none_or(str::is_empty);
let is_system_id_missing_or_legacy_compat = matches!(
system_identifier.as_deref(),
None | Some("") | Some("about:legacy-compat")
);
if name == Some("html") && is_public_id_missing && is_system_id_missing_or_legacy_compat {
return Vec::new();
}
let location = document.node(id).position.map(|position| SourceLocation {
line: position.line,
column: position.column,
byte_offset: position.byte_offset,
});
let message = if matches_limited_quirks_doctype(
public_identifier.as_deref(),
system_identifier.as_deref(),
) {
"almost standards mode doctype, expected “<!DOCTYPE html>”"
} else {
"obsolete doctype, expected “<!DOCTYPE html>”"
};
vec![Finding {
rule_id: "parser.html5".to_owned(),
severity: Severity::Error,
message: message.to_owned(),
location,
}]
}
fn matches_limited_quirks_doctype(
public_identifier: Option<&str>,
system_identifier: Option<&str>,
) -> bool {
let Some(public_identifier) = public_identifier else {
return false;
};
let starts_with_ci = |prefix: &str| {
public_identifier.len() >= prefix.len()
&& public_identifier[..prefix.len()].eq_ignore_ascii_case(prefix)
};
let system_id_present = !matches!(system_identifier, None | Some(""));
starts_with_ci("-//W3C//DTD XHTML 1.0 Frameset//")
|| starts_with_ci("-//W3C//DTD XHTML 1.0 Transitional//")
|| (system_id_present && starts_with_ci("-//W3C//DTD HTML 4.01 Frameset//"))
|| (system_id_present && starts_with_ci("-//W3C//DTD HTML 4.01 Transitional//"))
}
fn charset_after_1024_findings(document: &Document) -> Vec<Finding> {
let mut findings = Vec::new();
fn walk(document: &Document, id: html5_parser::NodeId, findings: &mut Vec<Finding>) {
let node = document.node(id);
if let NodeKind::Element {
name, attributes, ..
} = &node.kind
{
let is_meta = name.eq_ignore_ascii_case("meta");
let has_charset = is_meta
&& attributes.iter().any(|attr| {
attr.name.eq_ignore_ascii_case("charset")
|| (attr.name.eq_ignore_ascii_case("http-equiv")
&& attr.value.eq_ignore_ascii_case("content-type"))
});
if has_charset {
let pos = node
.position
.and_then(|p| (p.byte_offset > 1024).then_some(p));
if let Some(pos) = pos {
findings.push(Finding {
rule_id: "parser.html5".to_owned(),
severity: Severity::Error,
message:
"A “charset” attribute on a “meta” element found after the first 1024 bytes."
.to_owned(),
location: Some(SourceLocation {
line: pos.line,
column: pos.column,
byte_offset: pos.byte_offset,
}),
});
}
}
}
for child_id in document.children(id) {
walk(document, child_id, findings);
}
}
walk(document, document.root(), &mut findings);
findings
}
#[cfg(test)]
mod tests {
use html5_parser::NodeKind;
use super::{findings, parse};
#[test]
fn smoke_parses_minimal_html_to_tree() {
let parsed = parse("<p>Hello <b>world</b></p>");
let document = parsed.document();
let root_element = document
.children(document.root())
.find(|&node| matches!(document.node(node).kind, NodeKind::Element { .. }))
.expect("document should have a root element");
let NodeKind::Element { name, .. } = &document.node(root_element).kind else {
unreachable!("just matched as Element above");
};
assert_eq!(name, "html");
}
#[test]
fn retains_recoverable_parse_diagnostic_with_location() {
let parsed = parse("<!doctype html><p>¬AnEntity;</p>");
let parser_findings = findings(&parsed);
assert_eq!(parser_findings.len(), 1);
assert_eq!(parser_findings[0].rule_id, "parser.html5");
assert!(parser_findings[0].location.is_some());
}
#[test]
fn plain_doctype_html_has_no_doctype_finding() {
let parsed = parse("<!doctype html><title>t</title>");
assert!(findings(&parsed).is_empty());
}
#[test]
fn missing_doctype_is_a_finding_with_no_location() {
let parsed = parse("<meta charset=utf-8><title>no doctype</title>");
let parser_findings = findings(&parsed);
assert_eq!(parser_findings.len(), 1);
assert_eq!(parser_findings[0].rule_id, "parser.html5");
assert!(
parser_findings[0]
.message
.contains("without seeing a doctype")
);
assert_eq!(parser_findings[0].location, None);
}
#[test]
fn legacy_doctype_with_no_quirks_list_match_is_obsolete() {
let parsed = parse(
r#"<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#,
);
let parser_findings = findings(&parsed);
assert_eq!(parser_findings.len(), 1);
assert!(parser_findings[0].message.contains("obsolete doctype"));
assert!(parser_findings[0].location.is_some());
}
#[test]
fn quirky_doctype_with_no_system_id_is_also_obsolete() {
let parsed = parse(r#"<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN">"#);
let parser_findings = findings(&parsed);
assert_eq!(parser_findings.len(), 1);
assert!(parser_findings[0].message.contains("obsolete doctype"));
}
#[test]
fn html_4_01_transitional_with_system_id_is_almost_standards() {
let parsed = parse(
r#"<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">"#,
);
let parser_findings = findings(&parsed);
assert_eq!(parser_findings.len(), 1);
assert!(
parser_findings[0]
.message
.contains("almost standards mode doctype")
);
}
#[test]
fn about_legacy_compat_system_id_is_not_a_finding() {
let parsed = parse(r#"<!DOCTYPE html SYSTEM "about:legacy-compat">"#);
assert!(findings(&parsed).is_empty());
}
}