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