Skip to main content

rama_http/protocols/html/rewrite/
mod.rs

1//! Selector-driven HTML rewriting.
2//!
3//! The public entry point is [`HtmlRewriter`] (and the [`rewrite_str`]
4//! one-shot). Internally, a streaming selector matcher is fed start/end tags
5//! as they are tokenized and reports, at each start tag, which registered
6//! selectors match the element being opened — without building a DOM. It
7//! maintains an open-element stack and, per selector, two bitmasks:
8//!
9//!   * `completed` — which compound-prefixes are matched ending at this
10//!     element (a child combinator reads its parent's `completed`);
11//!   * `desc_ready` — which prefixes are available to descendants (a
12//!     descendant combinator reads its ancestor's `desc_ready`).
13//!
14//! It underpins the selector-driven [`HtmlRewriter`].
15
16use super::selector::Selector;
17use super::selector::ast::{Combinator, ComplexSelector, Compound, NthType};
18use super::tokenizer::{LocalNameHash, StartTag};
19
20mod element;
21mod rewriter;
22
23#[cfg(test)]
24mod tests;
25
26pub use self::element::{
27    AttributeName, Element, ElementContentHandler, HandlerResult, InvalidAttributeName,
28};
29pub use self::rewriter::{ElementContentHandlers, HtmlRewriter, rewrite_str};
30
31/// Selectors with more compounds than this can't be represented in the
32/// `u64` match bitmask; they simply never match (absurd in practice).
33const MAX_COMPOUNDS: usize = 63;
34
35// HTML void elements (the current WHATWG list): they never have children, so
36// they are not pushed onto the open-element stack.
37//
38// This mirrors the compile-time list in `rama-http-macros` (`is_void_tag`);
39// the two can't share a single const across the proc-macro/runtime boundary
40// (that crate only depends on proc-macro libraries). `param` is intentionally
41// absent — it is obsolete and not a void element in the current spec.
42const VOID_AREA: LocalNameHash = LocalNameHash::from_static(b"area");
43const VOID_BASE: LocalNameHash = LocalNameHash::from_static(b"base");
44const VOID_BR: LocalNameHash = LocalNameHash::from_static(b"br");
45const VOID_COL: LocalNameHash = LocalNameHash::from_static(b"col");
46const VOID_EMBED: LocalNameHash = LocalNameHash::from_static(b"embed");
47const VOID_HR: LocalNameHash = LocalNameHash::from_static(b"hr");
48const VOID_IMG: LocalNameHash = LocalNameHash::from_static(b"img");
49const VOID_INPUT: LocalNameHash = LocalNameHash::from_static(b"input");
50const VOID_LINK: LocalNameHash = LocalNameHash::from_static(b"link");
51const VOID_META: LocalNameHash = LocalNameHash::from_static(b"meta");
52const VOID_SOURCE: LocalNameHash = LocalNameHash::from_static(b"source");
53const VOID_TRACK: LocalNameHash = LocalNameHash::from_static(b"track");
54const VOID_WBR: LocalNameHash = LocalNameHash::from_static(b"wbr");
55
56// Optional-end-tag / implied-close elements. These cover the common HTML
57// tree-builder cases that affect streaming selector ancestry.
58const ADDRESS: LocalNameHash = LocalNameHash::from_static(b"address");
59const ARTICLE: LocalNameHash = LocalNameHash::from_static(b"article");
60const ASIDE: LocalNameHash = LocalNameHash::from_static(b"aside");
61const BLOCKQUOTE: LocalNameHash = LocalNameHash::from_static(b"blockquote");
62const DD: LocalNameHash = LocalNameHash::from_static(b"dd");
63const DETAILS: LocalNameHash = LocalNameHash::from_static(b"details");
64const DIV: LocalNameHash = LocalNameHash::from_static(b"div");
65const DL: LocalNameHash = LocalNameHash::from_static(b"dl");
66const DT: LocalNameHash = LocalNameHash::from_static(b"dt");
67const FIELDSET: LocalNameHash = LocalNameHash::from_static(b"fieldset");
68const FIGCAPTION: LocalNameHash = LocalNameHash::from_static(b"figcaption");
69const FIGURE: LocalNameHash = LocalNameHash::from_static(b"figure");
70const FOOTER: LocalNameHash = LocalNameHash::from_static(b"footer");
71const FORM: LocalNameHash = LocalNameHash::from_static(b"form");
72const H1: LocalNameHash = LocalNameHash::from_static(b"h1");
73const H2: LocalNameHash = LocalNameHash::from_static(b"h2");
74const H3: LocalNameHash = LocalNameHash::from_static(b"h3");
75const H4: LocalNameHash = LocalNameHash::from_static(b"h4");
76const H5: LocalNameHash = LocalNameHash::from_static(b"h5");
77const H6: LocalNameHash = LocalNameHash::from_static(b"h6");
78const HEADER: LocalNameHash = LocalNameHash::from_static(b"header");
79const HGROUP: LocalNameHash = LocalNameHash::from_static(b"hgroup");
80const LI: LocalNameHash = LocalNameHash::from_static(b"li");
81const MAIN: LocalNameHash = LocalNameHash::from_static(b"main");
82const MENU: LocalNameHash = LocalNameHash::from_static(b"menu");
83const NAV: LocalNameHash = LocalNameHash::from_static(b"nav");
84const OL: LocalNameHash = LocalNameHash::from_static(b"ol");
85const OPTGROUP: LocalNameHash = LocalNameHash::from_static(b"optgroup");
86const OPTION: LocalNameHash = LocalNameHash::from_static(b"option");
87const P: LocalNameHash = LocalNameHash::from_static(b"p");
88const PRE: LocalNameHash = LocalNameHash::from_static(b"pre");
89const RB: LocalNameHash = LocalNameHash::from_static(b"rb");
90const RP: LocalNameHash = LocalNameHash::from_static(b"rp");
91const RT: LocalNameHash = LocalNameHash::from_static(b"rt");
92const RTC: LocalNameHash = LocalNameHash::from_static(b"rtc");
93const SEARCH: LocalNameHash = LocalNameHash::from_static(b"search");
94const SECTION: LocalNameHash = LocalNameHash::from_static(b"section");
95const TABLE: LocalNameHash = LocalNameHash::from_static(b"table");
96const TBODY: LocalNameHash = LocalNameHash::from_static(b"tbody");
97const TD: LocalNameHash = LocalNameHash::from_static(b"td");
98const TFOOT: LocalNameHash = LocalNameHash::from_static(b"tfoot");
99const TH: LocalNameHash = LocalNameHash::from_static(b"th");
100const THEAD: LocalNameHash = LocalNameHash::from_static(b"thead");
101const TR: LocalNameHash = LocalNameHash::from_static(b"tr");
102const UL: LocalNameHash = LocalNameHash::from_static(b"ul");
103
104/// Whether `name` is an HTML void element. A `match` over the precomputed
105/// name hashes lets the compiler build a decision tree (a handful of integer
106/// compares), rather than a runtime slice scan.
107fn is_void(name: LocalNameHash) -> bool {
108    matches!(
109        name,
110        VOID_AREA
111            | VOID_BASE
112            | VOID_BR
113            | VOID_COL
114            | VOID_EMBED
115            | VOID_HR
116            | VOID_IMG
117            | VOID_INPUT
118            | VOID_LINK
119            | VOID_META
120            | VOID_SOURCE
121            | VOID_TRACK
122            | VOID_WBR
123    )
124}
125
126/// Per-selector NFA state carried by an open element.
127#[derive(Debug, Clone, Copy)]
128struct SelectorState {
129    /// Bit `j` set ⇒ the first `j` compounds matched ending at this element.
130    completed: u64,
131    /// Bit `i` set ⇒ prefix `i` is available to descendants (for descendant
132    /// combinators).
133    desc_ready: u64,
134}
135
136impl SelectorState {
137    const ROOT: Self = Self {
138        completed: 1,
139        desc_ready: 1,
140    };
141}
142
143/// An open element on the matching stack.
144#[derive(Debug)]
145struct Frame {
146    name: LocalNameHash,
147    child_count: usize,
148    type_counts: Vec<(LocalNameHash, usize)>,
149}
150
151impl Frame {
152    fn new(name: LocalNameHash) -> Self {
153        Self {
154            name,
155            child_count: 0,
156            type_counts: Vec::new(),
157        }
158    }
159
160    fn type_count(&self, name: LocalNameHash) -> usize {
161        self.type_counts
162            .iter()
163            .find(|(n, _)| *n == name)
164            .map_or(0, |(_, c)| *c)
165    }
166
167    fn record_child(&mut self, name: LocalNameHash, track_type: bool) {
168        self.child_count += 1;
169        // Per-type counts are only needed (and only allocate) when some
170        // selector uses `:nth-of-type`.
171        if track_type {
172            if let Some(entry) = self.type_counts.iter_mut().find(|(n, _)| *n == name) {
173                entry.1 += 1;
174            } else {
175                self.type_counts.push((name, 1));
176            }
177        }
178    }
179}
180
181/// Matches a fixed set of selectors against a stream of start/end tags.
182///
183/// Internal to the rewriter; not part of the public API.
184#[derive(Debug)]
185pub(crate) struct SelectorMatcher {
186    /// All complex selectors, flattened from the registered [`Selector`]s.
187    selectors: Vec<ComplexSelector>,
188    /// `owner[c]` is the index of the registered selector complex `c` came
189    /// from (what [`SelectorMatcher::push_element`] reports).
190    owner: Vec<usize>,
191    stack: Vec<Frame>,
192    /// Flat NFA state: row `r` (one per stack frame) holds `selectors.len()`
193    /// entries at `[r * n .. (r + 1) * n]`.
194    states: Vec<SelectorState>,
195    /// Reused scratch for a child row.
196    scratch: Vec<SelectorState>,
197    /// Reused scratch for de-duplicating matched selector indices.
198    matched: Vec<usize>,
199    /// Whether any selector uses `:nth-of-type` (gates per-type counting,
200    /// the only potential per-element allocation).
201    tracks_type: bool,
202}
203
204impl SelectorMatcher {
205    /// Builds a matcher for `selectors`. A start tag matching any complex
206    /// selector of registered selector `i` reports `i`.
207    #[must_use]
208    pub(crate) fn new(selectors: &[Selector]) -> Self {
209        let mut complexes = Vec::new();
210        let mut owner = Vec::new();
211        for (index, selector) in selectors.iter().enumerate() {
212            for complex in &selector.selectors {
213                complexes.push(complex.clone());
214                owner.push(index);
215            }
216        }
217        let n = complexes.len();
218        let tracks_type = complexes.iter().any(complex_uses_nth_of_type);
219        Self {
220            selectors: complexes,
221            owner,
222            stack: vec![Frame::new(LocalNameHash::NONE)],
223            states: vec![SelectorState::ROOT; n],
224            scratch: Vec::with_capacity(n),
225            matched: Vec::new(),
226            tracks_type,
227        }
228    }
229
230    /// Processes a start tag, invoking `on_match` once for each registered
231    /// selector index that matches the element being opened.
232    ///
233    /// Returns `true` if the element opened a new scope (was pushed onto the
234    /// stack) — i.e. it is neither void nor self-closing, so a matching end
235    /// tag is expected. The rewriter uses this to keep its deferred-action
236    /// stack in lockstep with the open-element stack.
237    pub(crate) fn push_element(
238        &mut self,
239        tag: &StartTag<'_>,
240        mut on_match: impl FnMut(usize),
241    ) -> bool {
242        let n = self.selectors.len();
243        let name = tag.name_hash();
244        let parent_index = self.stack.len() - 1;
245        let parent = &self.stack[parent_index];
246        let nth_child = parent.child_count + 1;
247        // Only meaningful (and only computed) when a selector uses it.
248        let nth_of_type = if self.tracks_type {
249            parent.type_count(name) + 1
250        } else {
251            1
252        };
253
254        self.scratch.clear();
255        self.matched.clear();
256        let parent_row = parent_index * n;
257        for s in 0..n {
258            let parent_state = self.states[parent_row + s];
259            let complex = &self.selectors[s];
260            let (state, matched) =
261                eval_selector(complex, parent_state, tag, nth_child, nth_of_type);
262            if matched && !self.matched.contains(&self.owner[s]) {
263                self.matched.push(self.owner[s]);
264            }
265            self.scratch.push(state);
266        }
267        for &index in &self.matched {
268            on_match(index);
269        }
270
271        self.stack[parent_index].record_child(name, self.tracks_type);
272
273        let opened = !is_void(name) && !tag.is_self_closing();
274        if opened {
275            self.stack.push(Frame::new(name));
276            self.states.extend_from_slice(&self.scratch);
277        }
278        opened
279    }
280
281    /// Applies start-tag implied end tags before a new element is matched.
282    ///
283    /// This mirrors the common optional-end-tag cases (`<li><li>`,
284    /// `<p><p>`, table cells/rows, options, ruby annotations). It keeps
285    /// selector ancestry and deferred rewriter actions aligned with the HTML
286    /// tree-builder shape for in-the-wild markup that legally omits end tags.
287    pub(crate) fn pop_implied_for_start(&mut self, name: LocalNameHash) -> usize {
288        let mut popped = 0;
289        match name {
290            LI => popped += self.pop_nearest(&[LI]),
291            DD | DT => popped += self.pop_nearest(&[DD, DT]),
292            OPTION => popped += self.pop_nearest(&[OPTION]),
293            OPTGROUP => {
294                popped += self.pop_nearest(&[OPTION]);
295                popped += self.pop_nearest(&[OPTGROUP]);
296            }
297            RB | RT | RTC | RP => popped += self.pop_nearest(&[RB, RT, RTC, RP]),
298            TR => {
299                popped += self.pop_nearest(&[TD, TH]);
300                popped += self.pop_nearest(&[TR]);
301            }
302            TD | TH => popped += self.pop_nearest(&[TD, TH]),
303            THEAD | TBODY | TFOOT => {
304                popped += self.pop_nearest(&[TD, TH]);
305                popped += self.pop_nearest(&[TR]);
306                popped += self.pop_nearest(&[THEAD, TBODY, TFOOT]);
307            }
308            _ if closes_p(name) => popped += self.pop_nearest(&[P]),
309            _ => {}
310        }
311        popped
312    }
313
314    /// Processes an end tag, closing the matching open element.
315    ///
316    /// Mirrors HTML's "generate implied end tags": it closes the *topmost*
317    /// open element with this `name` **and every still-open descendant above
318    /// it** (so crossed/unclosed tags like `<a><b></a>` close `b` too).
319    /// Returns the number of frames closed — `0` for a stray end tag that
320    /// matches nothing. The rewriter relies on this count to keep its
321    /// deferred-action stack and suppression depth in lockstep with the
322    /// open-element stack (otherwise a never-popped suppressing frame would
323    /// swallow the rest of the document).
324    pub(crate) fn pop_element(&mut self, name: LocalNameHash) -> usize {
325        // Frames hold only the name hash (not owned bytes, which would alloc
326        // per frame off the transient input buffer), so this match is by hash:
327        // two distinct names colliding (≈1/2^64 for 64-bit FNV) would mis-close
328        // an element. Not UB — at worst a mis-rewrite on adversarial input.
329        //
330        // Index 0 is the root sentinel and is never closed.
331        let Some(pos) = self.stack.iter().rposition(|f| f.name == name) else {
332            return 0;
333        };
334        if pos == 0 {
335            return 0;
336        }
337        let popped = self.stack.len() - pos;
338        self.stack.truncate(pos);
339        self.states.truncate(pos * self.selectors.len());
340        popped
341    }
342
343    /// Closes every still-open element at EOF.
344    pub(crate) fn finish(&mut self) -> usize {
345        let popped = self.stack.len().saturating_sub(1);
346        self.stack.truncate(1);
347        self.states.truncate(self.selectors.len());
348        popped
349    }
350
351    fn pop_nearest(&mut self, names: &[LocalNameHash]) -> usize {
352        let Some(pos) = self
353            .stack
354            .iter()
355            .rposition(|frame| names.contains(&frame.name))
356        else {
357            return 0;
358        };
359        if pos == 0 {
360            return 0;
361        }
362        let popped = self.stack.len() - pos;
363        self.stack.truncate(pos);
364        self.states.truncate(pos * self.selectors.len());
365        popped
366    }
367}
368
369fn closes_p(name: LocalNameHash) -> bool {
370    matches!(
371        name,
372        ADDRESS
373            | ARTICLE
374            | ASIDE
375            | BLOCKQUOTE
376            | DD
377            | DETAILS
378            | DIV
379            | DL
380            | DT
381            | FIELDSET
382            | FIGCAPTION
383            | FIGURE
384            | FOOTER
385            | FORM
386            | H1
387            | H2
388            | H3
389            | H4
390            | H5
391            | H6
392            | HEADER
393            | HGROUP
394            | VOID_HR
395            | MAIN
396            | MENU
397            | NAV
398            | OL
399            | P
400            | PRE
401            | SEARCH
402            | SECTION
403            | TABLE
404            | UL
405    )
406}
407
408fn complex_uses_nth_of_type(complex: &ComplexSelector) -> bool {
409    complex
410        .parts
411        .iter()
412        .any(|part| compound_uses_nth_of_type(&part.compound))
413}
414
415fn compound_uses_nth_of_type(compound: &Compound) -> bool {
416    compound.nth.iter().any(|nth| nth.ty == NthType::OfType)
417        || compound.negations.iter().any(compound_uses_nth_of_type)
418}
419
420/// Computes an element's NFA state for one complex selector, returning that
421/// state and whether the whole selector matches the element.
422fn eval_selector(
423    complex: &ComplexSelector,
424    parent: SelectorState,
425    tag: &StartTag<'_>,
426    nth_child: usize,
427    nth_of_type: usize,
428) -> (SelectorState, bool) {
429    let k = complex.parts.len();
430    if k == 0 || k > MAX_COMPOUNDS {
431        return (SelectorState::ROOT, false);
432    }
433
434    // Which slots can be matched at this element (bit 0 always).
435    let mut ready = 1u64;
436    for i in 1..k {
437        let bit = 1u64 << i;
438        match complex.parts[i].combinator {
439            Some(Combinator::Child) if parent.completed & bit != 0 => ready |= bit,
440            Some(Combinator::Descendant) if parent.desc_ready & bit != 0 => ready |= bit,
441            _ => {}
442        }
443    }
444
445    // Which prefixes complete at this element (bit 0 = empty prefix).
446    let mut completed = 1u64;
447    for i in 0..k {
448        if ready & (1u64 << i) != 0
449            && compound_matches(&complex.parts[i].compound, tag, nth_child, nth_of_type)
450        {
451            completed |= 1u64 << (i + 1);
452        }
453    }
454    let matched = completed & (1u64 << k) != 0;
455
456    // What flows down to descendants (descendant combinators only).
457    let mut desc_ready = 1u64;
458    for i in 1..k {
459        if complex.parts[i].combinator == Some(Combinator::Descendant) {
460            let bit = 1u64 << i;
461            if (completed | parent.desc_ready) & bit != 0 {
462                desc_ready |= bit;
463            }
464        }
465    }
466
467    (
468        SelectorState {
469            completed,
470            desc_ready,
471        },
472        matched,
473    )
474}
475
476/// Whether a single compound selector matches the element `tag` (at the
477/// given sibling positions). Combinators are handled by the caller.
478fn compound_matches(
479    compound: &Compound,
480    tag: &StartTag<'_>,
481    nth_child: usize,
482    nth_of_type: usize,
483) -> bool {
484    if let Some(name) = &compound.name
485        && !name.matches_bytes(tag.name())
486    {
487        return false;
488    }
489    if let Some(id) = &compound.id
490        && attribute(tag, b"id") != Some(id.as_bytes())
491    {
492        return false;
493    }
494    for class in &compound.classes {
495        let class = class.as_bytes();
496        let present = attribute(tag, b"class")
497            .is_some_and(|value| value.split(u8::is_ascii_whitespace).any(|c| c == class));
498        if !present {
499            return false;
500        }
501    }
502    for attr in &compound.attributes {
503        if !attribute(tag, attr.name.as_bytes()).is_some_and(|value| attr.matches_value(value)) {
504            return false;
505        }
506    }
507    for nth in &compound.nth {
508        let index = match nth.ty {
509            NthType::Child => nth_child,
510            NthType::OfType => nth_of_type,
511        };
512        if !nth.matches_index(index) {
513            return false;
514        }
515    }
516    !compound
517        .negations
518        .iter()
519        .any(|neg| compound_matches(neg, tag, nth_child, nth_of_type))
520}
521
522/// Looks up an attribute value by (ASCII case-insensitive) name.
523fn attribute<'i>(tag: &StartTag<'i>, name: &[u8]) -> Option<&'i [u8]> {
524    tag.attributes()
525        .find(|attr| attr.name().eq_ignore_ascii_case(name))
526        .map(|attr| attr.value())
527}