Skip to main content

quarb_compose/
lib.rs

1//! Adapter composition: descend *through* a leaf into its content.
2//!
3//! A filesystem's `.json` file, an archive's `config.xml` entry, a
4//! message's HTML part — trees of files are full of leaves whose
5//! *content* is itself a tree in another notation. Composition
6//! grafts that inner tree onto the outer one: wrap any adapter in
7//! a [`ComposeAdapter`] and a leaf whose name or content says
8//! "parse me" gains the parsed arbor as its children:
9//!
10//! ```text
11//! /data/store.json/books/*[/price:: < 12]/title::
12//!       └ outer (fs) ┘└──── inner (json) ────┘
13//! ```
14//!
15//! The graft happens on first touch, lazily: a leaf is only read
16//! and parsed when navigation actually enters it. Detection is by
17//! extension (`.json`, `.xml`, `.html`/`.htm`/`.xhtml`/`.svg`,
18//! `.csv`/`.tsv`), else by sniffing the content's first
19//! character; a parse failure simply leaves the leaf a leaf.
20//! Archive leaves (`.zip`, `.tar`, `.tar.gz`) are binary, so they
21//! graft by *path* rather than content — available when the outer
22//! substrate has real paths (see
23//! [`ComposeAdapter::with_source_paths`]) — and the grafted
24//! archive composes in turn, so one path runs filesystem → tar →
25//! JSON without a seam. The
26//! inner root is *identified with* the outer leaf node — the leaf
27//! itself answers the inner root's children — so there is no
28//! phantom node between the file and its content.
29//!
30//! Locators show the boundary with a bang, jar-URL style:
31//! `store.json!/books/1`.
32
33use quarb::{AstAdapter, NodeId, Value};
34use std::cell::RefCell;
35use std::collections::HashMap;
36use std::path::PathBuf;
37
38/// A parsed inner arbor.
39enum Inner {
40    Json(quarb_json::JsonAdapter),
41    Xml(quarb_xml::XmlAdapter),
42    Html(quarb_html::HtmlAdapter),
43    Text(quarb_text::TextModel),
44    Csv(quarb_csv::CsvAdapter),
45    #[cfg(feature = "source")]
46    Syntax(quarb_tree_sitter::TreeSitterAdapter),
47    #[cfg(feature = "source")]
48    Code(quarb_code::CodeModel),
49    /// A grafted archive, itself composed so its own parseable
50    /// entries graft in turn.
51    Archive(Box<ComposeAdapter<quarb_archive::ArchiveAdapter>>),
52}
53
54impl Inner {
55    fn adapter(&self) -> &dyn AstAdapter {
56        match self {
57            Inner::Json(a) => a,
58            Inner::Xml(a) => a,
59            Inner::Html(a) => a,
60            Inner::Text(a) => a,
61            Inner::Csv(a) => a,
62            #[cfg(feature = "source")]
63            Inner::Syntax(a) => a,
64            #[cfg(feature = "source")]
65            Inner::Code(a) => a,
66            Inner::Archive(a) => &**a,
67        }
68    }
69
70    fn locator(&self, node: NodeId) -> String {
71        match self {
72            Inner::Json(a) => a.pointer(node),
73            Inner::Xml(a) => a.locator(node),
74            Inner::Html(a) => a.locator(node),
75            Inner::Text(a) => a.locator(node),
76            Inner::Csv(a) => a.locator(node),
77            #[cfg(feature = "source")]
78            Inner::Syntax(a) => a.locator(node),
79            #[cfg(feature = "source")]
80            Inner::Code(a) => a.locator(node),
81            Inner::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
82        }
83    }
84}
85
86/// The level source-file leaves graft at: the syntax level (the
87/// literal parse — the default) or the code level (identifiers
88/// as names). Chosen per mount by the `code:` target prefix, and
89/// inherited by nested composes (an archive inside a `code:`
90/// mount grafts its source entries at the code level too) — the
91/// filesystem-into-declarations namespace has no seam.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum SourceGraft {
94    #[default]
95    Syntax,
96    Code,
97}
98
99/// The level marked-up documents (`.html`, `.htm`, `.md`) graft
100/// at: their DOM by default, or the text level — sections,
101/// paragraphs, lists, and what the page declares about itself
102/// in its head — when the mount asks for the reader's model
103/// (`text:` on an archive or directory).
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum DocumentGraft {
106    #[default]
107    Dom,
108    Text,
109}
110
111/// Names [`quarb_archive::ArchiveAdapter::open`] can take: the
112/// zip family, tar, and gzip (`.gz` alone included — the open
113/// checks magic bytes, and a failure leaves the leaf a leaf).
114fn archive_name(name: &str) -> bool {
115    let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
116    matches!(ext.as_str(), "zip" | "tar" | "tgz" | "gz")
117}
118
119/// Parse `content` by `name`'s extension, else by sniffing.
120fn parse_inner(name: &str, content: &str, graft: SourceGraft, doc: DocumentGraft) -> Option<Inner> {
121    #[cfg(not(feature = "source"))]
122    let _ = graft;
123    let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
124    match ext.as_str() {
125        "json" => {
126            return quarb_json::JsonAdapter::parse(content)
127                .ok()
128                .map(Inner::Json);
129        }
130        "jsonl" | "ndjson" => {
131            return quarb_json::JsonAdapter::parse_lines(content)
132                .ok()
133                .map(Inner::Json);
134        }
135        "xml" | "svg" | "xhtml" => {
136            return quarb_xml::XmlAdapter::parse(content).ok().map(Inner::Xml);
137        }
138        "html" | "htm" => {
139            return Some(match doc {
140                DocumentGraft::Dom => Inner::Html(quarb_html::HtmlAdapter::parse(content)),
141                DocumentGraft::Text => Inner::Text(quarb_text_html::parse(content)),
142            });
143        }
144        "yaml" | "yml" => return quarb_yaml::parse(content).ok().map(Inner::Json),
145        "toml" => return quarb_toml::parse(content).ok().map(Inner::Json),
146        "md" | "markdown" => {
147            return Some(match doc {
148                DocumentGraft::Dom => Inner::Html(quarb_markdown::parse(content)),
149                DocumentGraft::Text => Inner::Text(quarb_text_markdown::parse(content)),
150            });
151        }
152        // Plain text grafts at the text level (blank-line
153        // paragraphs); html/md keep their DOM-level graft.
154        "txt" => return Some(Inner::Text(quarb_text::TextModel::parse_plain(content))),
155        "csv" => return quarb_csv::CsvAdapter::parse(content).ok().map(Inner::Csv),
156        "tsv" => {
157            return quarb_csv::CsvAdapter::parse_with_delimiter(content, b'\t')
158                .ok()
159                .map(Inner::Csv);
160        }
161        #[cfg(feature = "source")]
162        ext if quarb_tree_sitter::supported(ext) => {
163            return match graft {
164                SourceGraft::Syntax => quarb_tree_sitter::TreeSitterAdapter::parse(content, ext)
165                    .ok()
166                    .map(Inner::Syntax),
167                SourceGraft::Code => quarb_code::CodeModel::parse(content, ext)
168                    .ok()
169                    .map(Inner::Code),
170            };
171        }
172        _ => {}
173    }
174    // Content sniff for extensionless names.
175    let t = content.trim_start();
176    if t.starts_with('{') || t.starts_with('[') {
177        return quarb_json::JsonAdapter::parse(content)
178            .ok()
179            .map(Inner::Json);
180    }
181    if t.starts_with("<?xml") {
182        return quarb_xml::XmlAdapter::parse(content).ok().map(Inner::Xml);
183    }
184    None
185}
186
187/// One graft: an inner arbor mounted at an outer leaf.
188struct Graft {
189    outer: NodeId,
190    inner: Inner,
191}
192
193/// Grafted node ids carry this tag bit; the rest indexes the intern
194/// table. The bit sits at the top of the 56-bit id window that
195/// `MountAdapter` leaves an inner adapter — it reserves the high byte
196/// (bits 56–63) for the mount index — so a grafted id survives being
197/// packed into a mount and still round-trips. It must stay below bit
198/// 56 for that reason; the top bit (1 << 63) collided with the mount
199/// byte. Outer adapters use small sequential indices, far below this
200/// bit, so a set bit 55 unambiguously marks a graft.
201const GRAFT_BIT: u64 = 1 << 55;
202
203/// Any adapter, with parseable leaf content grafted as subtrees.
204/// A host's reader for a document leaf: the path in, its text-level
205/// model out, or `None` to leave the leaf to the default grafting.
206pub type DocumentReader = std::rc::Rc<dyn Fn(&std::path::Path) -> Option<quarb_text::TextModel>>;
207
208pub struct ComposeAdapter<A: AstAdapter> {
209    outer: A,
210    grafts: RefCell<Vec<Graft>>,
211    /// Outer leaf → its graft index (`None`: probed, not
212    /// parseable).
213    probed: RefCell<HashMap<NodeId, Option<usize>>>,
214    /// (graft, inner id) → interned composite id, and the reverse.
215    interned: RefCell<HashMap<(usize, NodeId), NodeId>>,
216    reverse: RefCell<Vec<(usize, NodeId)>>,
217    /// Maps an outer leaf to a filesystem path, when the outer
218    /// substrate has one. Enables archive grafts, which are
219    /// binary and open by path rather than parsing from text.
220    source_path: Option<fn(&A, NodeId) -> Option<PathBuf>>,
221    /// The level source-file leaves graft at.
222    source_graft: SourceGraft,
223    /// The level marked-up documents graft at.
224    document_graft: DocumentGraft,
225    /// Maps an outer leaf to the URL its document is served
226    /// from, so a text-level graft joins relative references
227    /// against it and `-->` can land on a sibling document.
228    document_url: Option<fn(&A, NodeId) -> Option<String>>,
229    /// URL → outer leaf, built on first `document_by_ref`.
230    urls: RefCell<Option<HashMap<String, NodeId>>>,
231    /// The host's own reading of a document leaf, by path: when it
232    /// answers, its model is the graft (a corpus reading with its
233    /// tokens, a format this crate does not parse); when it
234    /// declines, the leaf grafts as it otherwise would.
235    document_reader: Option<DocumentReader>,
236}
237
238impl<A: AstAdapter> ComposeAdapter<A> {
239    pub fn new(outer: A) -> Self {
240        ComposeAdapter {
241            outer,
242            grafts: RefCell::new(Vec::new()),
243            probed: RefCell::new(HashMap::new()),
244            interned: RefCell::new(HashMap::new()),
245            reverse: RefCell::new(Vec::new()),
246            source_path: None,
247            source_graft: SourceGraft::default(),
248            document_graft: DocumentGraft::default(),
249            document_url: None,
250            urls: RefCell::new(None),
251            document_reader: None,
252        }
253    }
254
255    /// Hand document leaves to the host's reader first
256    /// (builder-style); needs the outer leaves' paths
257    /// ([`with_source_paths`](Self::with_source_paths)).
258    pub fn with_document_reader(mut self, reader: DocumentReader) -> Self {
259        self.document_reader = Some(reader);
260        self
261    }
262
263    /// Declare where each outer leaf's document is served from
264    /// (builder-style): a text-level graft joins its relative
265    /// references against that URL, and `-->` from any graft
266    /// lands on the leaf holding the referenced document (its
267    /// fragment's bearer when named) without a host registration.
268    pub fn with_document_urls(mut self, document_url: fn(&A, NodeId) -> Option<String>) -> Self {
269        self.document_url = Some(document_url);
270        self
271    }
272
273    /// The outer leaf serving `url` (fragment-stripped), from the
274    /// declared URLs; the whole outer tree is walked once.
275    fn leaf_by_url(&self, url: &str) -> Option<NodeId> {
276        let f = self.document_url?;
277        if self.urls.borrow().is_none() {
278            let mut map = HashMap::new();
279            let mut stack = vec![self.outer.root()];
280            while let Some(n) = stack.pop() {
281                let kids = self.outer.children(n);
282                if kids.is_empty() {
283                    if let Some(u) = f(&self.outer, n) {
284                        map.entry(u.split('#').next().unwrap_or(&u).to_string())
285                            .or_insert(n);
286                    }
287                } else {
288                    stack.extend(kids);
289                }
290            }
291            *self.urls.borrow_mut() = Some(map);
292        }
293        self.urls.borrow().as_ref()?.get(url).copied()
294    }
295
296    /// Choose the level source-file leaves graft at
297    /// (builder-style; the default is the syntax level).
298    pub fn with_source_graft(mut self, graft: SourceGraft) -> Self {
299        self.source_graft = graft;
300        self
301    }
302
303    /// Choose the level marked-up documents graft at
304    /// (builder-style; the default is the DOM).
305    pub fn with_document_graft(mut self, graft: DocumentGraft) -> Self {
306        self.document_graft = graft;
307        self
308    }
309
310    /// Like [`new`](Self::new), with a hook mapping outer leaves
311    /// to filesystem paths, so archive leaves (`.zip`,
312    /// `.tar[.gz]`) graft too. The grafted archive composes in
313    /// turn: its own parseable entries graft, and one path walks
314    /// filesystem → archive → document.
315    pub fn with_source_paths(outer: A, source_path: fn(&A, NodeId) -> Option<PathBuf>) -> Self {
316        ComposeAdapter {
317            source_path: Some(source_path),
318            ..Self::new(outer)
319        }
320    }
321
322    /// The wrapped adapter (for outer-specific calls).
323    pub fn outer(&self) -> &A {
324        &self.outer
325    }
326
327    /// A combined locator: `outer_locator(leaf)!inner-path` for
328    /// grafted nodes.
329    pub fn locator(&self, node: NodeId, outer_locator: impl Fn(NodeId) -> String) -> String {
330        match self.split(node) {
331            None => outer_locator(node),
332            Some((g, inner)) => {
333                let grafts = self.grafts.borrow();
334                let graft = &grafts[g];
335                format!(
336                    "{}!{}",
337                    outer_locator(graft.outer),
338                    graft.inner.locator(inner)
339                )
340            }
341        }
342    }
343
344    /// Decode a composite id.
345    /// The graft's fill for a node inside a grafted document: the
346    /// outer leaf's provenance (the file's path and mtime) with the
347    /// mtime re-labelled as the source rung, and the node's path
348    /// inside the document.
349    fn graft_fill(&self, outer_leaf: NodeId, inner_path: String) -> quarb::Provenance {
350        let mut fill = self.outer.provenance(outer_leaf).as_source_rung();
351        fill.path = Some(inner_path);
352        fill
353    }
354
355    fn split(&self, node: NodeId) -> Option<(usize, NodeId)> {
356        if node.0 & GRAFT_BIT == 0 {
357            return None;
358        }
359        self.reverse
360            .borrow()
361            .get((node.0 & !GRAFT_BIT) as usize)
362            .copied()
363    }
364
365    fn intern(&self, graft: usize, inner: NodeId) -> NodeId {
366        if let Some(&id) = self.interned.borrow().get(&(graft, inner)) {
367            return id;
368        }
369        let mut rev = self.reverse.borrow_mut();
370        let id = NodeId(GRAFT_BIT | rev.len() as u64);
371        rev.push((graft, inner));
372        self.interned.borrow_mut().insert((graft, inner), id);
373        id
374    }
375
376    /// The graft at an outer leaf, probing (read + parse) on first
377    /// touch. Only childless outer nodes with text content are
378    /// candidates.
379    fn graft_at(&self, node: NodeId) -> Option<usize> {
380        if let Some(&g) = self.probed.borrow().get(&node) {
381            return g;
382        }
383        let g = (|| {
384            if !self.outer.children(node).is_empty() {
385                return None;
386            }
387            let name = self.outer.name(node)?;
388            if archive_name(&name)
389                && let Some(path_of) = self.source_path
390                && let Some(path) = path_of(&self.outer, node)
391                && let Ok(a) = quarb_archive::ArchiveAdapter::open(&path)
392            {
393                let inner = Inner::Archive(Box::new(
394                    ComposeAdapter::new(a)
395                        .with_source_graft(self.source_graft)
396                        .with_document_graft(self.document_graft),
397                ));
398                let mut grafts = self.grafts.borrow_mut();
399                grafts.push(Graft { outer: node, inner });
400                return Some(grafts.len() - 1);
401            }
402            if let Some(read) = &self.document_reader
403                && let Some(path_of) = self.source_path
404                && let Some(path) = path_of(&self.outer, node)
405                && let Some(model) = read(&path)
406            {
407                let mut grafts = self.grafts.borrow_mut();
408                grafts.push(Graft {
409                    outer: node,
410                    inner: Inner::Text(model),
411                });
412                return Some(grafts.len() - 1);
413            }
414            let content = match self.outer.default_value(node)? {
415                Value::Str(s) => s,
416                _ => return None,
417            };
418            let mut inner = parse_inner(&name, &content, self.source_graft, self.document_graft)?;
419            if let (Inner::Text(model), Some(f)) = (&mut inner, self.document_url)
420                && let Some(u) = f(&self.outer, node)
421            {
422                model.set_document_url(&u);
423            }
424            let mut grafts = self.grafts.borrow_mut();
425            grafts.push(Graft { outer: node, inner });
426            Some(grafts.len() - 1)
427        })();
428        self.probed.borrow_mut().insert(node, g);
429        g
430    }
431
432    /// Map an inner node up: the inner root becomes the outer
433    /// leaf.
434    fn wrap(&self, graft: usize, inner: NodeId) -> NodeId {
435        let grafts = self.grafts.borrow();
436        if inner == grafts[graft].inner.adapter().root() {
437            grafts[graft].outer
438        } else {
439            drop(grafts);
440            self.intern(graft, inner)
441        }
442    }
443}
444
445impl<A: AstAdapter> AstAdapter for ComposeAdapter<A> {
446    fn root(&self) -> NodeId {
447        self.outer.root()
448    }
449
450    fn children(&self, node: NodeId) -> Vec<NodeId> {
451        match self.split(node) {
452            Some((g, inner)) => {
453                let ids: Vec<NodeId> = {
454                    let grafts = self.grafts.borrow();
455                    grafts[g].inner.adapter().children(inner)
456                };
457                ids.into_iter().map(|c| self.wrap(g, c)).collect()
458            }
459            None => {
460                let outer = self.outer.children(node);
461                if !outer.is_empty() {
462                    return outer;
463                }
464                match self.graft_at(node) {
465                    Some(g) => {
466                        let ids: Vec<NodeId> = {
467                            let grafts = self.grafts.borrow();
468                            let a = grafts[g].inner.adapter();
469                            a.children(a.root())
470                        };
471                        ids.into_iter().map(|c| self.wrap(g, c)).collect()
472                    }
473                    None => Vec::new(),
474                }
475            }
476        }
477    }
478
479    fn name(&self, node: NodeId) -> Option<String> {
480        match self.split(node) {
481            Some((g, inner)) => self.grafts.borrow()[g].inner.adapter().name(inner),
482            None => self.outer.name(node),
483        }
484    }
485
486    fn parent(&self, node: NodeId) -> Option<NodeId> {
487        match self.split(node) {
488            Some((g, inner)) => {
489                let p = self.grafts.borrow()[g].inner.adapter().parent(inner)?;
490                Some(self.wrap(g, p))
491            }
492            None => self.outer.parent(node),
493        }
494    }
495
496    /// A written name reaches a node by the owning adapter's rule
497    /// (its aliases included).
498    fn answers_to(&self, node: NodeId, name: &str) -> bool {
499        match self.split(node) {
500            Some((g, inner)) => self.grafts.borrow()[g]
501                .inner
502                .adapter()
503                .answers_to(inner, name),
504            None => self.outer.answers_to(node, name),
505        }
506    }
507
508    /// The graft root IS the outer leaf, so the leaf wears the
509    /// grafted document's own traits too — a page's declared tags
510    /// on the file that holds it.
511    fn traits(&self, node: NodeId) -> Vec<String> {
512        match self.split(node) {
513            Some((g, inner)) => self.grafts.borrow()[g].inner.adapter().traits(inner),
514            None => {
515                let mut out = self.outer.traits(node);
516                if let Some(g) = self.graft_at(node) {
517                    let grafts = self.grafts.borrow();
518                    let a = grafts[g].inner.adapter();
519                    for t in a.traits(a.root()) {
520                        if !out.contains(&t) {
521                            out.push(t);
522                        }
523                    }
524                }
525                out
526            }
527        }
528    }
529
530    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
531        match self.split(node) {
532            Some((g, inner)) => self.grafts.borrow()[g]
533                .inner
534                .adapter()
535                .property(inner, name),
536            // The graft root IS the outer leaf, so a property the
537            // outer can't answer falls through to the grafted
538            // document's root — `::key` dual exposure works *at*
539            // the graft, not only one level in.
540            None => self.outer.property(node, name).or_else(|| {
541                let g = self.graft_at(node)?;
542                let grafts = self.grafts.borrow();
543                let a = grafts[g].inner.adapter();
544                a.property(a.root(), name)
545            }),
546        }
547    }
548
549    fn default_value(&self, node: NodeId) -> Option<Value> {
550        match self.split(node) {
551            Some((g, inner)) => self.grafts.borrow()[g].inner.adapter().default_value(inner),
552            None => self.outer.default_value(node),
553        }
554    }
555
556    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
557        match self.split(node) {
558            Some((g, inner)) => self.grafts.borrow()[g].inner.adapter().metadata(inner, key),
559            // Like properties: what the outer cannot answer, the
560            // grafted document's root may (`::::"og:type"` on the
561            // file that holds the page).
562            None => self.outer.metadata(node, key).or_else(|| {
563                let g = self.graft_at(node)?;
564                let grafts = self.grafts.borrow();
565                let a = grafts[g].inner.adapter();
566                a.metadata(a.root(), key)
567            }),
568        }
569    }
570
571    /// A graft's own aliases are not merged: a node either belongs
572    /// to the outer document or to a graft, and the alias list is
573    /// consulted only after the owning adapter's `property` misses.
574    fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
575        match self.split(node) {
576            Some((g, inner)) => self.grafts.borrow()[g]
577                .inner
578                .adapter()
579                .aliased_metadata(inner),
580            None => self.outer.aliased_metadata(node),
581        }
582    }
583
584    /// A graft is a provenance layer: the grafted document's own
585    /// components win, and the outer *leaf* it was parsed from fills
586    /// the rest (with an fs outer: the file's path and mtime). The
587    /// jar-style composite locator is deliberately not the source —
588    /// `?src` names a document, and the intra-document address is
589    /// dpid's job.
590    fn provenance(&self, node: NodeId) -> quarb::Provenance {
591        match self.split(node) {
592            Some((g, inner)) => {
593                // Clone what we need out of the borrow before calling
594                // into `self.outer` (which may re-enter the graft
595                // tables).
596                let (inner_prov, outer_leaf, inner_path) = {
597                    let grafts = self.grafts.borrow();
598                    let a = grafts[g].inner.adapter();
599                    (
600                        a.provenance(inner),
601                        grafts[g].outer,
602                        quarb::name_path(a, inner),
603                    )
604                };
605                inner_prov.or(self.graft_fill(outer_leaf, inner_path))
606            }
607            None => self.outer.provenance(node),
608        }
609    }
610    /// The same layering, entry by entry.
611    fn provenance_list(&self, node: NodeId) -> quarb::ProvenanceList {
612        match self.split(node) {
613            Some((g, inner)) => {
614                let (inner_list, outer_leaf, inner_path) = {
615                    let grafts = self.grafts.borrow();
616                    let a = grafts[g].inner.adapter();
617                    (
618                        a.provenance_list(inner),
619                        grafts[g].outer,
620                        quarb::name_path(a, inner),
621                    )
622                };
623                inner_list.or(self.graft_fill(outer_leaf, inner_path))
624            }
625            None => self.outer.provenance_list(node),
626        }
627    }
628
629    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
630        match self.split(node) {
631            Some((g, inner)) => {
632                let t = self.grafts.borrow()[g]
633                    .inner
634                    .adapter()
635                    .resolve(inner, property, hint)?;
636                Some(self.wrap(g, t))
637            }
638            None => self.outer.resolve(node, property, hint),
639        }
640    }
641
642    /// A trait test reaches the graft — and, on the outer leaf,
643    /// the graft's root, as `traits` does.
644    fn has_trait(&self, node: NodeId, name: &str) -> bool {
645        match self.split(node) {
646            Some((g, inner)) => self.grafts.borrow()[g]
647                .inner
648                .adapter()
649                .has_trait(inner, name),
650            None => {
651                self.outer.has_trait(node, name)
652                    || self.graft_at(node).is_some_and(|g| {
653                        let grafts = self.grafts.borrow();
654                        let a = grafts[g].inner.adapter();
655                        a.has_trait(a.root(), name)
656                    })
657            }
658        }
659    }
660
661    fn ref_property(&self, node: NodeId) -> Option<String> {
662        match self.split(node) {
663            Some((g, inner)) => self.grafts.borrow()[g].inner.adapter().ref_property(inner),
664            None => self.outer.ref_property(node),
665        }
666    }
667
668    /// A graft's external references (a text-level ref leaving
669    /// its document) come through; an outer leaf's own do too.
670    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
671        match self.split(node) {
672            Some((g, inner)) => self.grafts.borrow()[g]
673                .inner
674                .adapter()
675                .external_ref(inner, property, hint),
676            None => self.outer.external_ref(node, property, hint),
677        }
678    }
679
680    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
681        match self.split(node) {
682            Some((g, inner)) => self.grafts.borrow()[g]
683                .inner
684                .adapter()
685                .ref_label(inner, property),
686            None => self.outer.ref_label(node, property),
687        }
688    }
689
690    /// A fragment lands inside the document: on an outer leaf
691    /// that holds a graft, inside that graft.
692    fn resolve_fragment(&self, node: NodeId, fragment: &str) -> Option<NodeId> {
693        match self.split(node) {
694            Some((g, inner)) => {
695                let t = self.grafts.borrow()[g]
696                    .inner
697                    .adapter()
698                    .resolve_fragment(inner, fragment)?;
699                Some(self.wrap(g, t))
700            }
701            None => match self.graft_at(node) {
702                Some(g) => {
703                    let t = {
704                        let grafts = self.grafts.borrow();
705                        let a = grafts[g].inner.adapter();
706                        a.resolve_fragment(a.root(), fragment)?
707                    };
708                    Some(self.wrap(g, t))
709                }
710                None => self.outer.resolve_fragment(node, fragment),
711            },
712        }
713    }
714
715    /// The leaf serving a URL, from the declared document URLs;
716    /// else whatever the outer answers.
717    fn document_by_ref(&self, id: &str) -> Option<NodeId> {
718        if let Some(leaf) = self.leaf_by_url(id) {
719            // The graft is parsed here so a fragment can land.
720            let _ = self.graft_at(leaf);
721            return Some(leaf);
722        }
723        self.outer.document_by_ref(id)
724    }
725
726    /// A reverse index answers on one side of the boundary only:
727    /// inside a graft, or on the outer tree when no graft could
728    /// hold a referrer (the outer's answer cannot see grafts).
729    fn reverse_resolve(
730        &self,
731        node: NodeId,
732        property: Option<&str>,
733        hint: Option<&str>,
734    ) -> Option<Vec<NodeId>> {
735        match self.split(node) {
736            Some((g, inner)) => {
737                let v = self.grafts.borrow()[g]
738                    .inner
739                    .adapter()
740                    .reverse_resolve(inner, property, hint)?;
741                Some(v.into_iter().map(|t| self.wrap(g, t)).collect())
742            }
743            None => None,
744        }
745    }
746
747    /// Inside a graft the graft's index answers; on the outer tree
748    /// a name may also live inside grafts, so the walk stays.
749    fn descendants_named(&self, node: NodeId, name: &str) -> Option<Vec<(NodeId, usize)>> {
750        let (g, inner) = self.split(node)?;
751        let v = self.grafts.borrow()[g]
752            .inner
753            .adapter()
754            .descendants_named(inner, name)?;
755        Some(v.into_iter().map(|(t, d)| (self.wrap(g, t), d)).collect())
756    }
757
758    fn prefetch_links(&self, nodes: &[NodeId], dir: quarb::LinkDir) {
759        let outer: Vec<NodeId> = nodes
760            .iter()
761            .copied()
762            .filter(|n| self.split(*n).is_none())
763            .collect();
764        if !outer.is_empty() {
765            self.outer.prefetch_links(&outer, dir);
766        }
767    }
768
769    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
770        match self.split(node) {
771            Some((g, inner)) => {
772                let ls: Vec<(String, NodeId)> = {
773                    let grafts = self.grafts.borrow();
774                    grafts[g].inner.adapter().links(inner)
775                };
776                ls.into_iter().map(|(l, n)| (l, self.wrap(g, n))).collect()
777            }
778            None => self.outer.links(node),
779        }
780    }
781
782    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
783        match self.split(node) {
784            Some((g, inner)) => {
785                let ls: Vec<(String, NodeId)> = {
786                    let grafts = self.grafts.borrow();
787                    grafts[g].inner.adapter().backlinks(inner)
788                };
789                ls.into_iter().map(|(l, n)| (l, self.wrap(g, n))).collect()
790            }
791            None => self.outer.backlinks(node),
792        }
793    }
794
795    fn link_property(
796        &self,
797        source: NodeId,
798        label: &str,
799        target: NodeId,
800        name: &str,
801    ) -> Option<Value> {
802        // An edge lives on one side of the graft boundary: both
803        // endpoints are inner (same graft) or both outer.
804        match (self.split(source), self.split(target)) {
805            (Some((g, src)), Some((gt, tgt))) if g == gt => self.grafts.borrow()[g]
806                .inner
807                .adapter()
808                .link_property(src, label, tgt, name),
809            (None, None) => self.outer.link_property(source, label, target, name),
810            _ => None,
811        }
812    }
813}