Skip to content

Commit 57427bd

Browse files
committed
feat(ie-html): add script/raw text states and character references #55
Complete the WHATWG tokenizer with all ~80 states: - RcData: entity resolution, appropriate end tag matching - RawText: raw character emission, end tag matching - ScriptData: all escape/double-escape states (~16 states) - PlainText: raw emission until EOF - Character references: named (via phf lookup of WHATWG entities.json), decimal (&#N;), hex (&#xN;), Windows-1252 replacements - build.rs codegen: generates phf::Map from vendored entities.json - entities.rs: longest_match for named refs, windows_1252_replacement - Data/RcData/attribute value states now handle '&' → CharacterReference - 9 entity unit tests + 14 new tokenizer tests (entities, RCDATA, RawText, ScriptData, state switching, multi-codepoint entities)
1 parent 1f575fd commit 57427bd

8 files changed

Lines changed: 3329 additions & 43 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ chrono = { version = "0.4", features = ["serde"] }
5858
tempfile = "3"
5959
base64 = "0.22"
6060
libc = "0.2"
61+
phf = { version = "0.11", features = ["macros"] }
62+
phf_codegen = "0.11"
6163

6264
[workspace.dependencies.landlock]
6365
version = "0.4"

crates/ie-html/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,10 @@ repository.workspace = true
1111
anyhow.workspace = true
1212
thiserror.workspace = true
1313
tracing.workspace = true
14+
phf.workspace = true
1415
ie-dom.workspace = true
16+
17+
[build-dependencies]
18+
phf_codegen.workspace = true
19+
serde.workspace = true
20+
serde_json.workspace = true

crates/ie-html/build.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use std::collections::BTreeMap;
2+
use std::env;
3+
use std::fs::File;
4+
use std::io::{BufWriter, Write};
5+
use std::path::Path;
6+
7+
use serde::Deserialize;
8+
9+
#[derive(Deserialize)]
10+
struct Entity {
11+
codepoints: Vec<u32>,
12+
#[allow(dead_code)]
13+
characters: String,
14+
}
15+
16+
fn main() {
17+
let out_dir = env::var("OUT_DIR").unwrap();
18+
let dest = Path::new(&out_dir).join("entities.rs");
19+
let mut file = BufWriter::new(File::create(dest).unwrap());
20+
21+
let json = include_str!("data/entities.json");
22+
let entities: BTreeMap<String, Entity> = serde_json::from_str(json).unwrap();
23+
24+
let mut builder = phf_codegen::Map::new();
25+
for (name, entity) in &entities {
26+
// Strip leading '&' — we look up names without it
27+
let key = name.strip_prefix('&').unwrap_or(name);
28+
let codepoints: Vec<String> = entity
29+
.codepoints
30+
.iter()
31+
.map(|c| format!("{c}u32"))
32+
.collect();
33+
builder.entry(key, &format!("&[{}]", codepoints.join(", ")));
34+
}
35+
36+
writeln!(
37+
file,
38+
"static NAMED_ENTITIES: phf::Map<&'static str, &'static [u32]> = {};",
39+
builder.build()
40+
)
41+
.unwrap();
42+
43+
println!("cargo:rerun-if-changed=data/entities.json");
44+
}

crates/ie-html/data/entities.json

Lines changed: 2233 additions & 0 deletions
Large diffs are not rendered by default.

crates/ie-html/src/entities.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
include!(concat!(env!("OUT_DIR"), "/entities.rs"));
2+
3+
/// Look up a named character reference. Key should NOT include leading '&'.
4+
/// Returns codepoints as &[u32].
5+
pub fn lookup(name: &str) -> Option<&'static [u32]> {
6+
NAMED_ENTITIES.get(name).copied()
7+
}
8+
9+
/// Find the longest matching entity name in the input.
10+
/// Input starts after '&'. Returns (matched_name, codepoints) if found.
11+
pub fn longest_match(input: &[char]) -> Option<(usize, &'static [u32])> {
12+
let mut best: Option<(usize, &'static [u32])> = None;
13+
let mut candidate = String::new();
14+
15+
for (i, &c) in input.iter().enumerate() {
16+
candidate.push(c);
17+
if let Some(codepoints) = lookup(&candidate) {
18+
best = Some((i + 1, codepoints));
19+
}
20+
// Stop searching if we hit ';' or a non-alphanumeric
21+
if c == ';' {
22+
break;
23+
}
24+
// The longest entity name in WHATWG is ~32 chars; cap search
25+
if i > 40 {
26+
break;
27+
}
28+
}
29+
best
30+
}
31+
32+
// Windows-1252 replacement table for numeric character references
33+
// See: https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
34+
pub fn windows_1252_replacement(codepoint: u32) -> Option<char> {
35+
match codepoint {
36+
0x80 => Some('\u{20AC}'),
37+
0x82 => Some('\u{201A}'),
38+
0x83 => Some('\u{0192}'),
39+
0x84 => Some('\u{201E}'),
40+
0x85 => Some('\u{2026}'),
41+
0x86 => Some('\u{2020}'),
42+
0x87 => Some('\u{2021}'),
43+
0x88 => Some('\u{02C6}'),
44+
0x89 => Some('\u{2030}'),
45+
0x8A => Some('\u{0160}'),
46+
0x8B => Some('\u{2039}'),
47+
0x8C => Some('\u{0152}'),
48+
0x8E => Some('\u{017D}'),
49+
0x91 => Some('\u{2018}'),
50+
0x92 => Some('\u{2019}'),
51+
0x93 => Some('\u{201C}'),
52+
0x94 => Some('\u{201D}'),
53+
0x95 => Some('\u{2022}'),
54+
0x96 => Some('\u{2013}'),
55+
0x97 => Some('\u{2014}'),
56+
0x98 => Some('\u{02DC}'),
57+
0x99 => Some('\u{2122}'),
58+
0x9A => Some('\u{0161}'),
59+
0x9B => Some('\u{203A}'),
60+
0x9C => Some('\u{0153}'),
61+
0x9E => Some('\u{017E}'),
62+
0x9F => Some('\u{0178}'),
63+
_ => None,
64+
}
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn lookup_amp() {
73+
assert_eq!(lookup("amp;"), Some(&[38u32][..]));
74+
}
75+
76+
#[test]
77+
fn lookup_lt() {
78+
assert_eq!(lookup("lt;"), Some(&[60u32][..]));
79+
}
80+
81+
#[test]
82+
fn lookup_nonexistent() {
83+
assert_eq!(lookup("notanentity;"), None);
84+
}
85+
86+
#[test]
87+
fn lookup_without_semicolon() {
88+
assert_eq!(lookup("amp"), Some(&[38u32][..]));
89+
}
90+
91+
#[test]
92+
fn longest_match_amp() {
93+
let input: Vec<char> = "amp;text".chars().collect();
94+
let (len, cp) = longest_match(&input).unwrap();
95+
assert_eq!(len, 4); // "amp;"
96+
assert_eq!(cp, &[38]);
97+
}
98+
99+
#[test]
100+
fn longest_match_no_semicolon() {
101+
let input: Vec<char> = "amptext".chars().collect();
102+
let (len, cp) = longest_match(&input).unwrap();
103+
assert_eq!(len, 3); // "amp"
104+
assert_eq!(cp, &[38]);
105+
}
106+
107+
#[test]
108+
fn longest_match_multi_codepoint() {
109+
// &nGt; maps to [8811, 8402] (≫⃒)
110+
let input: Vec<char> = "nGt;".chars().collect();
111+
let result = longest_match(&input);
112+
assert!(result.is_some());
113+
let (_, cp) = result.unwrap();
114+
assert_eq!(cp.len(), 2);
115+
}
116+
117+
#[test]
118+
fn windows_1252_euro() {
119+
assert_eq!(windows_1252_replacement(0x80), Some('\u{20AC}'));
120+
}
121+
122+
#[test]
123+
fn windows_1252_no_replacement() {
124+
assert_eq!(windows_1252_replacement(0x41), None);
125+
}
126+
}

crates/ie-html/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! WHATWG HTML Living Standard parser.
44
//! Targets latest spec only — no quirks mode, no legacy element support.
55
6+
pub mod entities;
67
pub mod token;
78
pub mod tokenizer;
89
pub mod tree_builder;

0 commit comments

Comments
 (0)