Skip to main content

quarb_html/
lib.rs

1//! HTML adapter for Quarb.
2//!
3//! Maps an HTML document onto the arbor model, parsing with
4//! html5ever (via `scraper`) so malformed markup is handled per the
5//! HTML5 standard.
6//!
7//! - Elements are the nodes; text and comment nodes are not navigated
8//!   (text is exposed as a projection instead).
9//! - A node's *name* is its tag (`html`, `body`, `div`, `h1`, `a`),
10//!   so `/html/body//p` navigates by element type. The document root
11//!   is unnamed; the `<html>` element is its single child.
12//! - A node's *traits* are structural classes: `<block>` or
13//!   `<inline>`, plus `<heading>` for `h1`–`h6` and `<link>` for an
14//!   anchor with an `href`.
15//! - Attributes are properties: `::href`, `::class`, `::id`. The
16//!   default projection (`::`) is the element's text content;
17//!   `::::tag`, `::::classes`, `::::attrs`, and `::::n-attrs`
18//!   expose adapter metadata.
19//! - An anchor's fragment `href` resolves: `::href-->` follows
20//!   `#section` to the element with `id="section"`.
21
22use quarb::names::Names;
23use quarb::{AstAdapter, NodeId, Value};
24mod sink;
25use std::collections::HashMap;
26
27struct Node {
28    /// The tag name, in the document's name table; `None` for the
29    /// document root.
30    tag: Option<u32>,
31    /// The attributes: the name in the table, the value.
32    attrs: Vec<(u32, String)>,
33    /// The element's content in document order: its own text
34    /// children as written, its element children by ordinal in
35    /// `children`. The subtree text (the bare projection) is
36    /// concatenated on first use and kept — storing it for every
37    /// element up front cost the depth of the tree per element.
38    pieces: Vec<Piece>,
39    text: std::cell::OnceCell<String>,
40    parent: Option<NodeId>,
41    children: Vec<NodeId>,
42}
43
44/// One piece of an element's content.
45enum Piece {
46    Text(String),
47    Child(usize),
48}
49
50impl Node {
51    fn attr(&self, names: &Names, name: &str) -> Option<&str> {
52        let k = names.key(name)?;
53        self.attrs
54            .iter()
55            .find(|(a, _)| *a == k)
56            .map(|(_, v)| v.as_str())
57    }
58}
59
60/// The encoding an HTML document declares in its head — a `<meta
61/// charset=…>` or a `<meta http-equiv="content-type"
62/// content="…; charset=…">` within the first 1024 bytes, as the
63/// HTML standard's prescan reads them — reported for the engine's
64/// source boundary. The adapter decodes nothing. An XML prologue
65/// on an XHTML page counts too (the XML adapter's rule, applied
66/// here so a page served as text/html still declares). A
67/// `charset=` inside any other attribute's value (a description)
68/// declares nothing.
69pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
70    if let Some(label) = xml_prologue_encoding(bytes) {
71        return Some(label);
72    }
73    let head = &bytes[..bytes.len().min(1024)];
74    let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
75    let mut at = 0;
76    while let Some(i) = find(&lower[at..], b"<meta") {
77        let start = at + i + 5;
78        let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
79        let attrs = meta_attributes(&lower[start..end]);
80        let get = |name: &str| {
81            attrs
82                .iter()
83                .find(|(k, _)| k == name)
84                .map(|(_, v)| v.as_str())
85        };
86        if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
87            return Some(label.to_string());
88        }
89        if get("http-equiv") == Some("content-type")
90            && let Some(content) = get("content")
91            && let Some(p) = content.find("charset=")
92        {
93            let label = content[p + 8..]
94                .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
95                .split([';', ' ', '"', '\''])
96                .next()
97                .unwrap_or("");
98            if !label.is_empty() {
99                return Some(label.to_string());
100            }
101        }
102        at = end;
103    }
104    None
105}
106
107/// `<?xml … encoding="…"?>` at the very start — the one XML rule
108/// the HTML reporter shares, kept local so the crates stay apart.
109/// The target must be `xml` itself (`<?xml-stylesheet …?>` is
110/// another instruction and declares nothing).
111fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
112    let head = bytes.strip_prefix(b"<?xml")?;
113    if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
114        return None;
115    }
116    let end = find(head, b"?>")?;
117    let prologue = std::str::from_utf8(&head[..end]).ok()?;
118    let rest = prologue
119        .split_once("encoding")?
120        .1
121        .trim_start()
122        .strip_prefix('=')?
123        .trim_start();
124    let quote = rest.chars().next()?;
125    (quote == '"' || quote == '\'')
126        .then(|| {
127            rest[1..]
128                .split(quote)
129                .next()
130                .unwrap_or("")
131                .trim()
132                .to_string()
133        })
134        .filter(|l| !l.is_empty())
135}
136
137fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
138    hay.windows(needle.len()).position(|w| w == needle)
139}
140
141/// The attributes of a lowercased tag body, as (name, value): a
142/// value quoted or bare, a name without one given the empty value.
143/// Quoted values are skipped as wholes, so nothing inside one is
144/// taken for an attribute.
145fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
146    let mut out = Vec::new();
147    let mut i = 0;
148    let n = tag.len();
149    while i < n {
150        while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
151            i += 1;
152        }
153        let name_start = i;
154        while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
155            i += 1;
156        }
157        if i == name_start {
158            break;
159        }
160        let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
161        while i < n && tag[i].is_ascii_whitespace() {
162            i += 1;
163        }
164        let mut value = String::new();
165        if i < n && tag[i] == b'=' {
166            i += 1;
167            while i < n && tag[i].is_ascii_whitespace() {
168                i += 1;
169            }
170            if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
171                let q = tag[i];
172                i += 1;
173                let v = i;
174                while i < n && tag[i] != q {
175                    i += 1;
176                }
177                value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
178                i = (i + 1).min(n);
179            } else {
180                let v = i;
181                while i < n && !tag[i].is_ascii_whitespace() {
182                    i += 1;
183                }
184                value = String::from_utf8_lossy(&tag[v..i]).to_string();
185            }
186        }
187        out.push((name, value));
188    }
189    out
190}
191
192/// A Quarb adapter over a parsed HTML document.
193pub struct HtmlAdapter {
194    nodes: Vec<Node>,
195    /// The document's tag and attribute names, each once.
196    names: Names,
197    /// `id` attribute value → the element carrying it.
198    ids: HashMap<String, NodeId>,
199    root: NodeId,
200    /// The document's own URL, when the mount knows it — the base
201    /// a relative `href` joins against for `::href-->` across
202    /// documents. `None` for pasted or fileless html: only
203    /// absolute hrefs reference out then.
204    document_url: Option<url::Url>,
205}
206
207impl HtmlAdapter {
208    /// Does `node`'s `rel` admit the relation `hint`? No hint and
209    /// the `*` wildcard admit everything; a named hint needs the
210    /// space-separated `rel` list to contain it (`rel="nofollow
211    /// noopener"` answers to either word). An element without a
212    /// `rel` answers only the wildcard forms — the spec's
213    /// `-->stylesheet` reads "only the stylesheet relation".
214    fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
215        match hint {
216            None | Some("*") => true,
217            Some(h) => self.nodes[node.0 as usize]
218                .attr(&self.names, "rel")
219                .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
220        }
221    }
222
223    /// Declare the document's own URL — the base a relative `href`
224    /// joins against when `::href-->` reaches across documents.
225    pub fn set_document_url(&mut self, url: &str) {
226        self.document_url = url::Url::parse(url).ok();
227    }
228
229    /// The subtree text of `node` — every descendant text node in
230    /// document order — concatenated on first use and kept. An
231    /// explicit stack, so a document nested twenty thousand
232    /// elements deep does not recurse.
233    fn text_of(&self, node: NodeId) -> &str {
234        self.nodes[node.0 as usize].text.get_or_init(|| {
235            let mut out = String::new();
236            // (element, next piece, where its text starts in `out`):
237            // an element whose pieces are spent keeps its own text,
238            // so a filter over every element of a deep tree reads
239            // each subtree once.
240            let mut stack: Vec<(usize, usize, usize)> = vec![(node.0 as usize, 0, 0)];
241            while let Some(top) = stack.last_mut() {
242                let (cur, at, start) = *top;
243                let n = &self.nodes[cur];
244                if at >= n.pieces.len() {
245                    if cur != node.0 as usize {
246                        let _ = n.text.set(out[start..].to_string());
247                    }
248                    stack.pop();
249                    continue;
250                }
251                top.1 += 1;
252                match &n.pieces[at] {
253                    Piece::Text(t) => out.push_str(t),
254                    Piece::Child(k) => {
255                        let c = n.children[*k].0 as usize;
256                        match self.nodes[c].text.get() {
257                            Some(t) => out.push_str(t),
258                            None => stack.push((c, 0, out.len())),
259                        }
260                    }
261                }
262            }
263            out
264        })
265    }
266
267    /// Parse `html` and build the adapter.
268    pub fn parse(html: &str) -> Self {
269        use html5ever::tendril::TendrilSink;
270        let sink =
271            html5ever::driver::parse_document(sink::Sink::new(), Default::default()).one(html);
272        let mut nodes = vec![Node {
273            tag: None,
274            attrs: Vec::new(),
275            pieces: vec![Piece::Child(0)],
276            text: std::cell::OnceCell::new(),
277            parent: None,
278            children: Vec::new(),
279        }];
280        let mut ids = HashMap::new();
281        let root = NodeId(0);
282        let sink::Sink {
283            nodes: mut sunk,
284            mut names,
285        } = sink;
286        // The document element: the first element child of the
287        // sink's document.
288        let html_el = sunk[0].kids.iter().find_map(|k| match k {
289            sink::Kid::Node(n) if !sunk[*n].skip => Some(*n),
290            _ => None,
291        });
292        if let Some(html_el) = html_el {
293            let html_node = build(
294                html_el,
295                Some(root),
296                &mut sunk,
297                &mut nodes,
298                &mut names,
299                &mut ids,
300            );
301            nodes[0].children = vec![html_node];
302        }
303
304        HtmlAdapter {
305            nodes,
306            names,
307            ids,
308            root,
309            document_url: None,
310        }
311    }
312
313    /// A locator path to `node`, like `/html/body/div[2]/p`, for
314    /// rendering. A `[n]` index is added only to disambiguate same-tag
315    /// siblings.
316    pub fn locator(&self, node: NodeId) -> String {
317        let mut segments = Vec::new();
318        let mut cur = Some(node);
319        while let Some(id) = cur {
320            let n = &self.nodes[id.0 as usize];
321            if let Some(tag) = n.tag {
322                segments.push(self.segment(id, tag));
323            }
324            cur = n.parent;
325        }
326        segments.reverse();
327        format!("/{}", segments.join("/"))
328    }
329
330    fn segment(&self, node: NodeId, tag: u32) -> String {
331        let name = self.names.get(tag);
332        let Some(parent) = self.nodes[node.0 as usize].parent else {
333            return name.to_string();
334        };
335        let siblings = &self.nodes[parent.0 as usize].children;
336        let same_tag: Vec<NodeId> = siblings
337            .iter()
338            .copied()
339            .filter(|&s| self.nodes[s.0 as usize].tag == Some(tag))
340            .collect();
341        if same_tag.len() > 1 {
342            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
343            format!("{name}[{n}]")
344        } else {
345            name.to_string()
346        }
347    }
348}
349
350/// Intern the sink's element `el` and its subtree into the arena:
351/// text pieces and element children in order, the strings moved
352/// out of the sink. Iterative (an explicit work stack) so deep
353/// markup cannot overflow the call stack; elements are interned in
354/// document (pre-order) order, so node ids, child ordering and the
355/// first-occurrence `ids` map follow the document.
356fn build(
357    el: usize,
358    parent: Option<NodeId>,
359    sunk: &mut [sink::SinkNode],
360    nodes: &mut Vec<Node>,
361    names: &mut Names,
362    ids: &mut HashMap<String, NodeId>,
363) -> NodeId {
364    let root_this = NodeId(nodes.len() as u64);
365    let mut stack = vec![(el, parent)];
366    while let Some((el, parent)) = stack.pop() {
367        let this = NodeId(nodes.len() as u64);
368        let s = &mut sunk[el];
369        let tag = names.intern(&s.name.as_ref().expect("an element").local);
370        let attrs = std::mem::take(&mut s.attrs);
371        let kids = std::mem::take(&mut s.kids);
372        let mut pieces = Vec::new();
373        let mut children: Vec<usize> = Vec::new();
374        let mut ordinal = 0;
375        for k in kids {
376            match k {
377                sink::Kid::Text(t) => pieces.push(Piece::Text(t)),
378                sink::Kid::Node(n) => {
379                    if sunk[n].skip {
380                        continue;
381                    }
382                    pieces.push(Piece::Child(ordinal));
383                    ordinal += 1;
384                    children.push(n);
385                }
386            }
387        }
388        if let Some(k) = names.key("id")
389            && let Some((_, id)) = attrs.iter().find(|(a, _)| *a == k)
390        {
391            ids.entry(id.clone()).or_insert(this);
392        }
393        nodes.push(Node {
394            tag: Some(tag),
395            attrs,
396            pieces,
397            text: std::cell::OnceCell::new(),
398            parent,
399            children: Vec::new(),
400        });
401        if let Some(p) = parent {
402            nodes[p.0 as usize].children.push(this);
403        }
404        for child in children.into_iter().rev() {
405            stack.push((child, Some(this)));
406        }
407    }
408    root_this
409}
410
411/// Block-level HTML element tags.
412const BLOCK: &[&str] = &[
413    "address",
414    "article",
415    "aside",
416    "blockquote",
417    "body",
418    "details",
419    "dialog",
420    "dd",
421    "div",
422    "dl",
423    "dt",
424    "fieldset",
425    "figcaption",
426    "figure",
427    "footer",
428    "form",
429    "h1",
430    "h2",
431    "h3",
432    "h4",
433    "h5",
434    "h6",
435    "header",
436    "hgroup",
437    "hr",
438    "html",
439    "li",
440    "main",
441    "nav",
442    "ol",
443    "p",
444    "pre",
445    "section",
446    "table",
447    "ul",
448];
449
450fn is_heading(tag: &str) -> bool {
451    matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
452}
453
454impl AstAdapter for HtmlAdapter {
455    /// The arena knows how many nodes a step reaches; nothing is read.
456    fn estimate(&self, scope: &[&str], plan: &quarb::StepPlan<'_>) -> Option<quarb::Estimate> {
457        quarb::adapter::arena_estimate(self, scope, plan, quarb::Latency::Memory, None)
458    }
459
460    fn root(&self) -> NodeId {
461        self.root
462    }
463
464    /// In memory: an unscoped reverse resolution or value match may
465    /// walk the arbor.
466    fn walks_for_references(&self) -> bool {
467        true
468    }
469
470    fn children(&self, node: NodeId) -> Vec<NodeId> {
471        self.nodes[node.0 as usize].children.clone()
472    }
473
474    fn name(&self, node: NodeId) -> Option<String> {
475        self.nodes[node.0 as usize]
476            .tag
477            .map(|t| self.names.get(t).to_string())
478    }
479
480    fn parent(&self, node: NodeId) -> Option<NodeId> {
481        self.nodes[node.0 as usize].parent
482    }
483
484    /// Structural traits: `<block>` or `<inline>`, plus `<heading>`
485    /// for `h1`–`h6` and `<link>` for an anchor with an `href`.
486    fn traits(&self, node: NodeId) -> Vec<String> {
487        let n = &self.nodes[node.0 as usize];
488        let Some(tag) = n.tag.map(|t| self.names.get(t)) else {
489            return Vec::new();
490        };
491        let mut out = Vec::new();
492        if BLOCK.contains(&tag) {
493            out.push("block".to_string());
494        } else {
495            out.push("inline".to_string());
496        }
497        if is_heading(tag) {
498            out.push("heading".to_string());
499        }
500        if tag == "a" && n.attr(&self.names, "href").is_some() {
501            out.push("link".to_string());
502        }
503        out
504    }
505
506    /// Attributes, by name (`::href`). Element text is the bare
507    /// projection (`::`) — no attribute name is shadowed.
508    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
509        self.nodes[node.0 as usize]
510            .attr(&self.names, name)
511            .map(|v| Value::Str(v.to_string()))
512    }
513
514    /// The default projection of an element is its text content.
515    fn default_value(&self, node: NodeId) -> Option<Value> {
516        Some(Value::Str(self.text_of(node).to_string()))
517    }
518
519    /// `::::tag`, `::::classes` (the class attribute, split),
520    /// `::::attrs` (the attribute names, sorted — the parser does
521    /// not preserve source order), and `::::n-attrs` — facts about
522    /// the element, never its data: attribute values are properties
523    /// (`::href`), text is the bare projection. `attrs` is what
524    /// makes a renamed attribute discoverable: enumerate the names,
525    /// probe the values by shape.
526    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
527        let n = &self.nodes[node.0 as usize];
528        match key {
529            "tag" => n.tag.map(|t| Value::Str(self.names.get(t).to_string())),
530            "classes" => n.attr(&self.names, "class").map(|c| {
531                Value::list(
532                    c.split_whitespace()
533                        .map(|s| Value::Str(s.to_string()))
534                        .collect(),
535                )
536            }),
537            "attrs" => {
538                let mut names: Vec<&str> =
539                    n.attrs.iter().map(|(k, _)| self.names.get(*k)).collect();
540                names.sort_unstable();
541                Some(Value::list(
542                    names
543                        .into_iter()
544                        .map(|k| Value::Str(k.to_string()))
545                        .collect(),
546                ))
547            }
548            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
549            _ => None,
550        }
551    }
552
553    /// Follow an attribute that is a fragment reference (`#section`) to
554    /// the element with that `id`. Used by an anchor's `::href-->`.
555    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
556        if property == "href" && !self.rel_ok(node, hint) {
557            return None;
558        }
559        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
560        let fragment = value.strip_prefix('#')?;
561        self.ids.get(fragment).copied()
562    }
563
564    /// The external reference an `href`-like attribute holds: the
565    /// absolute URL, relative values joined against the document's
566    /// own URL (when the mount declared one). A bare `#fragment`
567    /// stays in-document (that is `resolve`'s job); a fragment on
568    /// a cross-document URL rides along — it is the crossref
569    /// within the target, landed by `resolve_fragment` once that
570    /// document is mounted. Non-web schemes reference nothing
571    /// followable. Feeds `::href-->` across documents — the
572    /// acquisition arrow.
573    fn external_ref(
574        &self,
575        node: NodeId,
576        property: &str,
577        hint: Option<&str>,
578    ) -> Option<quarb::ExternalRef> {
579        if property == "href" && !self.rel_ok(node, hint) {
580            return None;
581        }
582        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
583        let t = value.trim();
584        if t.is_empty() || t.starts_with('#') {
585            return None;
586        }
587        let u = match url::Url::parse(t) {
588            Ok(u) => u,
589            // A relative reference: joined against the document's
590            // URL where one is known; else handed over as written,
591            // for the engine to make absolute against the tree the
592            // page stands in (a file beside it in a directory).
593            Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
594                Some(base) => base.join(t).ok()?,
595                None => return Some(quarb::ExternalRef::uri(t)),
596            },
597            Err(_) => return None,
598        };
599        if u.scheme() != "http" && u.scheme() != "https" {
600            return None;
601        }
602        Some(quarb::ExternalRef::uri(u.as_str()))
603    }
604
605    /// html's fragment landing: the element whose `id` is
606    /// `fragment`, anywhere in this document.
607    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
608        self.ids.get(fragment).copied()
609    }
610
611    /// The bare arrow's property: an anchor-family element's
612    /// `href`, a frame's `src`.
613    /// An element's properties are its attributes.
614    fn property_names(&self, node: NodeId) -> Vec<String> {
615        self.nodes[node.0 as usize]
616            .attrs
617            .iter()
618            .map(|(k, _)| self.names.get(*k).to_string())
619            .collect()
620    }
621
622    /// Any attribute may hold a reference (`#id`, an IDREF) to any
623    /// element of the document: the format declares no narrower one.
624    fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
625        Some(true)
626    }
627
628    fn ref_property(&self, node: NodeId) -> Option<String> {
629        let n = &self.nodes[node.0 as usize];
630        let (prop, tags): (&str, &[&str]) = match n.tag.map(|t| self.names.get(t)) {
631            Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
632            Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
633            _ => return None,
634        };
635        let _ = tags;
636        n.attr(&self.names, prop).map(|_| prop.to_string())
637    }
638
639    /// The relation an href edge carries: the `rel` attribute —
640    /// `<link rel="stylesheet">`, `<a rel="nofollow">`. The spec's
641    /// `-->stylesheet` hint filters on it; absent, the edge label
642    /// falls back to the property name.
643    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
644        if property != "href" {
645            return None;
646        }
647        let rel = self.nodes[node.0 as usize].attr(&self.names, "rel")?;
648        let rel = rel.trim();
649        (!rel.is_empty()).then(|| rel.to_string())
650    }
651}
652
653#[cfg(test)]
654mod declared_tests {
655    use super::declared_encoding;
656
657    #[test]
658    fn meta_charset_forms() {
659        assert_eq!(
660            declared_encoding(
661                b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
662            ),
663            Some("windows-1251".into())
664        );
665        assert_eq!(
666            declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
667            Some("koi8-r".into())
668        );
669        assert_eq!(
670            declared_encoding(b"<html><head><meta charset=utf-8></head>"),
671            Some("utf-8".into())
672        );
673        assert_eq!(
674            declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
675            None
676        );
677        assert_eq!(
678            declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
679            Some("ISO-8859-5".into())
680        );
681        // a charset= inside another attribute's value declares nothing
682        assert_eq!(
683            declared_encoding(
684                b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
685            ),
686            None
687        );
688        // another processing instruction is no prologue
689        assert_eq!(
690            declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
691            None
692        );
693        let mut late = vec![b' '; 1100];
694        late.extend_from_slice(b"<meta charset=\"koi8-r\">");
695        assert_eq!(declared_encoding(&late), None);
696    }
697}