Skip to main content

astral_html/
document.rs

1//! A flat, read-only document with lexical element scopes.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::fmt;
6use std::num::NonZeroUsize;
7use std::ops::Range;
8
9use crate::tokenizer::matches_normalized_name;
10use crate::{Attribute, Reader, Token};
11
12/// Resource limits for constructing a document.
13///
14/// The input limit is checked before tokenization. Node and depth limits are
15/// checked before retaining the next node. These checks are not fallible
16/// allocation or process-wide memory accounting.
17#[derive(Debug, Clone, Copy)]
18pub struct Limits {
19    /// Maximum number of input bytes (default: 128 MiB).
20    pub max_input_bytes: usize,
21    /// Maximum total number of retained element and text nodes (default: 4 million).
22    pub max_nodes: usize,
23    /// Maximum number of simultaneously open non-void elements (default: 256).
24    pub max_depth: usize,
25}
26
27impl Default for Limits {
28    fn default() -> Self {
29        Self {
30            max_input_bytes: 128 * 1024 * 1024,
31            max_nodes: 4_000_000,
32            max_depth: 256,
33        }
34    }
35}
36
37/// A configured resource limit was exceeded.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Error {
40    /// The input is larger than the configured byte limit.
41    InputLimit,
42    /// The document has more nodes than allowed.
43    NodeLimit,
44    /// The document has more simultaneously open elements than allowed.
45    DepthLimit,
46}
47
48impl fmt::Display for Error {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter.write_str(match self {
51            Self::InputLimit => "HTML input exceeds the byte limit",
52            Self::NodeLimit => "HTML document exceeds the node limit",
53            Self::DepthLimit => "HTML document exceeds the nesting limit",
54        })
55    }
56}
57
58impl std::error::Error for Error {}
59
60/// An immutable source-order document with borrowed strings.
61///
62/// Each emitted start tag creates an element. An end tag closes the innermost
63/// open element with that name and its descendants; unmatched end tags are
64/// ignored. HTML void elements close immediately. The self-closing flag on other
65/// HTML elements is ignored. Remaining elements close at EOF. No elements are
66/// implied, relocated, or cloned: this is not the HTML browser tree-building algorithm.
67///
68/// Construction, traversal, and destruction use flat storage without recursion.
69pub struct Document<'a> {
70    nodes: Vec<Node<'a>>,
71    attributes: Vec<Attribute<'a>>,
72}
73
74struct Node<'a> {
75    kind: Kind<'a>,
76    // One-based parent index reserves zero for root nodes.
77    parent: Option<NonZeroUsize>,
78    // Exclusive subtree endpoint; text and void elements end at the next node.
79    end: usize,
80}
81
82enum Kind<'a> {
83    Element(ElementData<'a>),
84    Text(Cow<'a, str>),
85}
86
87struct ElementData<'a> {
88    name: Cow<'a, str>,
89    attributes: Range<usize>,
90}
91
92struct Open {
93    node: usize,
94    // Previous stack depth with the same name, once the name index is enabled.
95    previous: Option<usize>,
96}
97
98impl<'a> Document<'a> {
99    /// Parse a document with the default resource limits.
100    pub fn parse(source: &'a str) -> Result<Self, Error> {
101        Self::parse_with_limits(source, Limits::default())
102    }
103
104    /// Parse a document with caller-selected resource limits.
105    ///
106    /// Malformed HTML follows tokenizer recovery and lexical scope rules. Only
107    /// resource-limit violations return errors; no partial document is returned.
108    pub fn parse_with_limits(source: &'a str, limits: Limits) -> Result<Self, Error> {
109        if source.len() > limits.max_input_bytes {
110            return Err(Error::InputLimit);
111        }
112        let mut document = Self {
113            nodes: Vec::with_capacity(source.len().min(limits.max_nodes).min(16)),
114            attributes: Vec::new(),
115        };
116        let mut open: Vec<Open> = Vec::new();
117        // Matching the top scope needs no index. Promote a deep stack on its
118        // first mismatch, so repeated unmatched end tags remain bounded.
119        let mut names: Option<HashMap<Cow<'a, str>, usize>> = None;
120        let mut reader = Reader::new(source);
121        let mut attribute_buffer = Vec::new();
122        while let Some(token) = reader.next_with_attribute_buffer(&mut attribute_buffer) {
123            let mut attributes = match token {
124                Token::StartTag(mut tag) => {
125                    let is_void = is_void(&tag.name);
126                    if !is_void && open.len() >= limits.max_depth {
127                        return Err(Error::DepthLimit);
128                    }
129                    let index = document.nodes.len();
130                    if index >= limits.max_nodes {
131                        return Err(Error::NodeLimit);
132                    }
133                    let parent = open
134                        .last()
135                        .and_then(|entry| NonZeroUsize::new(entry.node + 1));
136                    if !is_void {
137                        let previous = names
138                            .as_mut()
139                            .and_then(|names| names.insert(tag.name.clone(), open.len()));
140                        open.push(Open {
141                            node: index,
142                            previous,
143                        });
144                    }
145                    let start = document.attributes.len();
146                    if document.attributes.is_empty() {
147                        std::mem::swap(&mut document.attributes, &mut tag.attributes);
148                    } else {
149                        document.attributes.append(&mut tag.attributes);
150                    }
151                    document.nodes.push(Node {
152                        kind: Kind::Element(ElementData {
153                            name: tag.name,
154                            attributes: start..document.attributes.len(),
155                        }),
156                        parent,
157                        end: index + 1,
158                    });
159                    tag.attributes
160                }
161                Token::EndTag(tag) => {
162                    let matches_top = open.last().is_some_and(|entry| {
163                        matches!(&document.nodes[entry.node].kind, Kind::Element(start) if start.name == tag.name)
164                    });
165                    if !matches_top && open.len() > 8 && names.is_none() {
166                        let mut index = HashMap::new();
167                        for (depth, entry) in open.iter_mut().enumerate() {
168                            let Kind::Element(tag) = &document.nodes[entry.node].kind else {
169                                unreachable!()
170                            };
171                            entry.previous = index.insert(tag.name.clone(), depth);
172                        }
173                        names = Some(index);
174                    }
175                    let depth = if matches_top {
176                        Some(open.len() - 1)
177                    } else if let Some(names) = &names {
178                        names.get(tag.name.as_ref()).copied()
179                    } else {
180                        open.iter().rposition(|entry| {
181                            matches!(&document.nodes[entry.node].kind, Kind::Element(start) if start.name == tag.name)
182                        })
183                    };
184                    if let Some(depth) = depth {
185                        document.close(&mut open, &mut names, depth);
186                    }
187                    tag.attributes
188                }
189                Token::Text(text) => {
190                    if !text.is_empty() {
191                        let index = document.nodes.len();
192                        if index >= limits.max_nodes {
193                            return Err(Error::NodeLimit);
194                        }
195                        document.nodes.push(Node {
196                            kind: Kind::Text(text),
197                            parent: open
198                                .last()
199                                .and_then(|entry| NonZeroUsize::new(entry.node + 1)),
200                            end: index + 1,
201                        });
202                    }
203                    continue;
204                }
205                Token::Comment(_) | Token::Doctype(_) | Token::ProcessingInstruction { .. } => {
206                    continue;
207                }
208            };
209            attributes.clear();
210            if attributes.capacity() > attribute_buffer.capacity() {
211                attribute_buffer = attributes;
212            }
213        }
214        document.close(&mut open, &mut names, 0);
215        Ok(document)
216    }
217
218    /// Close open elements through `depth`, restoring previous same-name scopes.
219    fn close(
220        &mut self,
221        open: &mut Vec<Open>,
222        names: &mut Option<HashMap<Cow<'a, str>, usize>>,
223        depth: usize,
224    ) {
225        let end = self.nodes.len();
226        while open.len() > depth {
227            let entry = open.pop().expect("nonempty open stack");
228            let node = &mut self.nodes[entry.node];
229            node.end = end;
230            let Kind::Element(tag) = &node.kind else {
231                unreachable!()
232            };
233            if let Some(names) = names {
234                if let Some(previous) = entry.previous {
235                    *names
236                        .get_mut(tag.name.as_ref())
237                        .expect("open name is indexed") = previous;
238                } else {
239                    names.remove(tag.name.as_ref());
240                }
241            }
242        }
243    }
244
245    /// Iterate over every element in source order.
246    pub fn elements(&self) -> impl Iterator<Item = Element<'_, 'a>> {
247        self.elements_in(0, self.nodes.len())
248    }
249
250    fn elements_in(&self, start: usize, end: usize) -> impl Iterator<Item = Element<'_, 'a>> {
251        (start..end).filter_map(|index| {
252            matches!(self.nodes[index].kind, Kind::Element(_)).then_some(Element {
253                document: self,
254                index,
255            })
256        })
257    }
258}
259
260/// A borrowed view of an element in a [`Document`].
261#[derive(Clone, Copy)]
262pub struct Element<'doc, 'src> {
263    document: &'doc Document<'src>,
264    index: usize,
265}
266
267impl<'doc, 'src> Element<'doc, 'src> {
268    fn data(self) -> &'doc ElementData<'src> {
269        let Kind::Element(tag) = &self.document.nodes[self.index].kind else {
270            unreachable!()
271        };
272        tag
273    }
274
275    /// Return the normalized name, with ASCII letters lowercased.
276    pub fn name(self) -> &'doc str {
277        &self.data().name
278    }
279
280    /// Compare an HTML element name without ASCII case sensitivity.
281    pub fn is(self, name: &str) -> bool {
282        let actual = self.name();
283        actual == name || matches_normalized_name(actual.as_bytes(), name.as_bytes())
284    }
285
286    /// Find an attribute without ASCII case sensitivity. The first occurrence wins.
287    ///
288    /// Values are already decoded; [`Attribute::raw_value`] preserves their
289    /// source spelling.
290    #[inline]
291    pub fn attribute(self, name: &str) -> Option<&'doc Attribute<'src>> {
292        self.attributes().find(|attribute| {
293            attribute.name == name
294                || matches_normalized_name(attribute.name.as_bytes(), name.as_bytes())
295        })
296    }
297
298    /// Test for an attribute, including a boolean attribute with no value.
299    pub fn has_attribute(self, name: &str) -> bool {
300        self.attribute(name).is_some()
301    }
302
303    /// Iterate over the element's attributes in source order, without duplicates.
304    pub fn attributes(self) -> impl Iterator<Item = &'doc Attribute<'src>> {
305        self.document.attributes[self.data().attributes.clone()].iter()
306    }
307
308    /// Return the containing lexical element, or `None` for a root element.
309    pub fn parent(self) -> Option<Self> {
310        self.document.nodes[self.index].parent.map(|index| Self {
311            document: self.document,
312            index: index.get() - 1,
313        })
314    }
315
316    /// Iterate over descendant elements, excluding this element, in source order.
317    pub fn descendants(self) -> impl Iterator<Item = Self> {
318        self.document
319            .elements_in(self.index + 1, self.document.nodes[self.index].end)
320    }
321
322    /// Iterate over direct child elements in source order.
323    pub fn children(self) -> impl Iterator<Item = Self> {
324        let mut cursor = self.index + 1;
325        let end = self.document.nodes[self.index].end;
326        std::iter::from_fn(move || {
327            while cursor < end {
328                let index = cursor;
329                let node = &self.document.nodes[index];
330                cursor = node.end;
331                if matches!(node.kind, Kind::Element(_)) {
332                    return Some(Self {
333                        document: self.document,
334                        index,
335                    });
336                }
337            }
338            None
339        })
340    }
341
342    /// Concatenate descendant text in source order, excluding comments and declarations.
343    ///
344    /// Character references are decoded according to the tokenizer state and
345    /// remain literal in script/style contents. No layout whitespace is inserted.
346    /// A single segment borrows from the document; multiple segments allocate.
347    /// With no text, this returns a borrowed empty string.
348    #[inline]
349    pub fn text(self) -> Cow<'doc, str> {
350        let end = self.document.nodes[self.index].end;
351        let mut segments = self.document.nodes[self.index + 1..end]
352            .iter()
353            .filter_map(|node| {
354                if let Kind::Text(text) = &node.kind {
355                    Some(text.as_ref())
356                } else {
357                    None
358                }
359            });
360        let Some(first) = segments.next() else {
361            return Cow::Borrowed("");
362        };
363        let Some(second) = segments.next() else {
364            return Cow::Borrowed(first);
365        };
366        Cow::Owned(concatenate_text(first, second, segments))
367    }
368}
369
370/// Allocate only after a second text segment rules out a borrowed result.
371#[inline(never)]
372fn concatenate_text<'a>(
373    first: &str,
374    second: &str,
375    remaining: impl Iterator<Item = &'a str>,
376) -> String {
377    let mut text = String::with_capacity(first.len() + second.len());
378    text.push_str(first);
379    text.push_str(second);
380    for segment in remaining {
381        text.push_str(segment);
382    }
383    text
384}
385
386impl fmt::Debug for Element<'_, '_> {
387    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
388        formatter
389            .debug_struct("Element")
390            .field("name", &self.name())
391            .field("index", &self.index)
392            .finish()
393    }
394}
395
396fn is_void(name: &str) -> bool {
397    matches!(
398        name,
399        "area"
400            | "base"
401            | "br"
402            | "col"
403            | "embed"
404            | "hr"
405            | "img"
406            | "input"
407            | "link"
408            | "meta"
409            | "param"
410            | "source"
411            | "track"
412            | "wbr"
413    )
414}