Skip to main content

quarb_session/
doc.rs

1//! Opening a source into a queryable adapter, and running queries
2//! against it.
3//!
4//! `AstAdapter` is object-safe, but each adapter's *render* method
5//! (`pointer` / `locator` / `path`) is an inherent method, not on the
6//! trait — so, as the Python bindings do, we hold one of a fixed set
7//! of adapter families in an enum and dispatch render (and the
8//! `WithNow`/`AllowShell` query wrap) by variant.
9//!
10//! The text-format variants always compile (they are wasm-safe); the
11//! native fleet (filesystem, git, SQLite, archives, spreadsheets,
12//! source code, mounts) is gated behind the `native` feature, as is
13//! the filesystem `open`/`mount` dispatch. The wasm build drives
14//! everything through [`Doc::parse`].
15
16use anyhow::{Context, Result, bail};
17use quarb::{AllowShell, NodeId, QueryResult, WithNow};
18
19#[cfg(feature = "native")]
20use std::path::Path;
21use std::rc::Rc;
22
23/// Options that shape how native sources open (unused on wasm, which
24/// only parses text).
25#[derive(Clone, Default)]
26pub struct Options {
27    pub hidden: bool,
28    pub respect_ignore: bool,
29    /// Opt directory mounts into grafting (qua's --graft).
30    pub graft: bool,
31    /// Disable grafting entirely (qua's --no-graft).
32    pub no_graft: bool,
33    /// Declared references, `(field, container)` pairs — the parsed
34    /// `--refs` document, consumed by the SQLite mounts.
35    pub refs: Rc<Vec<(String, String)>>,
36}
37
38/// A materialized source: one variant per adapter family. JSON-model
39/// formats (json/yaml/toml) render node results as pointers, the rest
40/// as locators.
41pub enum Doc {
42    Json(quarb_json::JsonAdapter),
43    Csv(quarb_csv::CsvAdapter),
44    Xml(quarb_xml::XmlAdapter),
45    Html(quarb_html::HtmlAdapter),
46    Text(quarb_text::TextModel),
47    #[cfg(feature = "sqlite")]
48    Sqlite(quarb_sqlite::SqliteAdapter),
49    #[cfg(feature = "native")]
50    Fs(quarb_fs::FsAdapter),
51    #[cfg(feature = "native")]
52    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
53    #[cfg(feature = "native")]
54    Git(quarb_git::GitAdapter),
55    #[cfg(feature = "native")]
56    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
57    /// An archive held opaque (`no_graft`): the member tree with
58    /// leaves as plain entries — the tar -t view.
59    #[cfg(feature = "native")]
60    ArchiveRaw(quarb_archive::ArchiveAdapter),
61    #[cfg(feature = "native")]
62    Xlsx(quarb_xlsx::XlsxAdapter),
63    #[cfg(feature = "native")]
64    Syntax(quarb_tree_sitter::TreeSitterAdapter),
65    #[cfg(feature = "native")]
66    Code(quarb_code::CodeModel),
67    Mount(quarb_mount::MountAdapter),
68    /// Any adapter behind the object-safe trait, with its locator
69    /// renderer — the carrier for scheme targets opened through
70    /// qua's dispatch (`gcl:`, `kafka:`, `neo4j://`, …).
71    Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
72}
73
74/// A boxed adapter as an adapter — plain delegation (the
75/// quarb-py `Dyn` pattern).
76pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
77
78impl quarb::AstAdapter for Dyn {
79    fn root(&self) -> NodeId {
80        self.0.root()
81    }
82    fn children(&self, node: NodeId) -> Vec<NodeId> {
83        self.0.children(node)
84    }
85    fn name(&self, node: NodeId) -> Option<String> {
86        self.0.name(node)
87    }
88    fn parent(&self, node: NodeId) -> Option<NodeId> {
89        self.0.parent(node)
90    }
91    fn traits(&self, node: NodeId) -> Vec<String> {
92        self.0.traits(node)
93    }
94    fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
95        self.0.property(node, name)
96    }
97    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
98        self.0.children_named(node, name)
99    }
100    fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
101        self.0.default_value(node)
102    }
103    fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
104        self.0.metadata(node, key)
105    }
106    fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
107        self.0.aliased_metadata(node)
108    }
109    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
110        self.0.links(node)
111    }
112    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
113        self.0.backlinks(node)
114    }
115    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
116        self.0.resolve(node, property, hint)
117    }
118    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
119        self.0.external_ref(node, property, hint)
120    }
121    fn resolve_fragment(&self, node: NodeId, fragment: &str) -> Option<NodeId> {
122        self.0.resolve_fragment(node, fragment)
123    }
124    fn ref_property(&self, node: NodeId) -> Option<String> {
125        self.0.ref_property(node)
126    }
127    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
128        self.0.ref_label(node, property)
129    }
130    fn link_property(
131        &self,
132        source: NodeId,
133        label: &str,
134        target: NodeId,
135        name: &str,
136    ) -> Option<quarb::Value> {
137        self.0.link_property(source, label, target, name)
138    }
139    fn quantifier_bound(&self) -> usize {
140        self.0.quantifier_bound()
141    }
142    fn invocation_instant(&self) -> Option<(i64, u32)> {
143        self.0.invocation_instant()
144    }
145    fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
146        self.0.unit_scale(expr)
147    }
148    // The rest of the surface, forwarded too: an adapter behind
149    // this door keeps its own answers to names and traits (a web
150    // level's rows answer to their kind), its index hooks, its
151    // provenance, and its shell gate.
152    fn answers_to(&self, node: NodeId, name: &str) -> bool {
153        self.0.answers_to(node, name)
154    }
155    fn has_trait(&self, node: NodeId, name: &str) -> bool {
156        self.0.has_trait(node, name)
157    }
158    fn has_node(&self, node: NodeId) -> bool {
159        self.0.has_node(node)
160    }
161    fn document_by_ref(&self, id: &str) -> Option<NodeId> {
162        self.0.document_by_ref(id)
163    }
164    fn reverse_resolve(
165        &self,
166        node: NodeId,
167        property: Option<&str>,
168        hint: Option<&str>,
169    ) -> Option<Vec<NodeId>> {
170        self.0.reverse_resolve(node, property, hint)
171    }
172    fn descendants_named(&self, node: NodeId, name: &str) -> Option<Vec<(NodeId, usize)>> {
173        self.0.descendants_named(node, name)
174    }
175    fn prefetch_links(&self, nodes: &[NodeId], dir: quarb::LinkDir) {
176        self.0.prefetch_links(nodes, dir)
177    }
178    fn provenance(&self, node: NodeId) -> quarb::Provenance {
179        self.0.provenance(node)
180    }
181    fn provenance_list(&self, node: NodeId) -> quarb::ProvenanceList {
182        self.0.provenance_list(node)
183    }
184    fn allow_shell(&self) -> bool {
185        self.0.allow_shell()
186    }
187}
188
189impl Doc {
190    /// The kaiv door: the adapter is Rc-shared between the Doc and
191    /// its locator renderer (the `Shared` pattern qua's scheme
192    /// mounts use), riding the Boxed variant.
193    fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
194        let a = std::rc::Rc::new(a);
195        let r = a.clone();
196        Doc::Boxed(
197            Dyn(Box::new(quarb_mount::Shared(a))),
198            Box::new(move |n| r.locator(n)),
199        )
200    }
201
202    /// Parse a text document by format name — the wasm entry point,
203    /// and the text tail of the native `open`. Formats: json, yaml,
204    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson, kaiv/daiv.
205    pub fn parse(input: &str, format: &str) -> Result<Doc> {
206        match format {
207            // kaiv rides the Boxed door (no dedicated variant): the
208            // offline resolver — a browser mount has no filesystem
209            // or registry, so `.!units`/`.!types` imports beyond the
210            // embedded core fail with kaiv's own pointed error.
211            "kaiv" => {
212                let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
213                    .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
214                return Ok(Self::boxed_kaiv(a));
215            }
216            "daiv" => {
217                let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
218                    .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
219                return Ok(Self::boxed_kaiv(a));
220            }
221            "json" => quarb_json::JsonAdapter::parse(input)
222                .map(Doc::Json)
223                .context("parsing JSON"),
224            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
225                .map(Doc::Json)
226                .context("parsing JSONL"),
227            "yaml" | "yml" => quarb_yaml::parse(input)
228                .map(Doc::Json)
229                .context("parsing YAML"),
230            "toml" => quarb_toml::parse(input)
231                .map(Doc::Json)
232                .context("parsing TOML"),
233            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
234                .map(Doc::Csv)
235                .context("parsing CSV"),
236            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
237                .map(Doc::Csv)
238                .context("parsing TSV"),
239            "xml" => quarb_xml::XmlAdapter::parse(input)
240                .map(Doc::Xml)
241                .context("parsing XML"),
242            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
243            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
244            // LaTeX source, through the text-level producer — a
245            // pure text parser, so the wasm build carries it too.
246            "tex" | "latex" => Ok(Doc::Text(quarb_text_latex::parse(input))),
247            // The text level: the shared section/paragraph
248            // vocabulary, produced per source format ("text" is
249            // plain text — blank-line paragraphs).
250            "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
251            "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
252            "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
253            // BibTeX / BibLaTeX, imported through atrep's
254            // bibliogramma: a document of bib entries.
255            #[cfg(feature = "koine")]
256            "text-bibtex" | "bibtex" => quarb_text_koine::parse_bibtex(input)
257                .map(Doc::Text)
258                .map_err(|e| anyhow::anyhow!("{e}")),
259            // The code level: identifiers as names; the format
260            // name carries the language.
261            #[cfg(feature = "native")]
262            "code-rust" => quarb_code::CodeModel::parse(input, "rs")
263                .map(Doc::Code)
264                .context("parsing Rust at the code level"),
265            #[cfg(feature = "native")]
266            "code-python" => quarb_code::CodeModel::parse(input, "py")
267                .map(Doc::Code)
268                .context("parsing Python at the code level"),
269            #[cfg(feature = "native")]
270            "code-javascript" => quarb_code::CodeModel::parse(input, "js")
271                .map(Doc::Code)
272                .context("parsing JavaScript at the code level"),
273            #[cfg(feature = "native")]
274            "code-c" => quarb_code::CodeModel::parse(input, "c")
275                .map(Doc::Code)
276                .context("parsing C at the code level"),
277            other => bail!("unknown format: {other}"),
278        }
279    }
280
281    /// Run one query against this source with the session's invocation
282    /// instant and shell permission. The query text carries any macro
283    /// definitions inline (the session prepends its table), which
284    /// `quarb::run` expands.
285    /// The concrete adapter behind this `Doc`, as `&dyn` — the base a
286    /// `--model` enrichment layer wraps (one match, so the model
287    /// paths avoid duplicating the variant arms). Wasm-safe; the
288    /// native-only variants are compiled in only under `native`.
289    pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
290        match self {
291            Doc::Json(a) => a,
292            Doc::Csv(a) => a,
293            Doc::Xml(a) => a,
294            Doc::Html(a) => a,
295            Doc::Text(a) => a,
296            #[cfg(feature = "sqlite")]
297            Doc::Sqlite(a) => a,
298            #[cfg(feature = "native")]
299            Doc::Fs(a) => a,
300            #[cfg(feature = "native")]
301            Doc::FsDeep(a) => a,
302            #[cfg(feature = "native")]
303            Doc::Git(a) => a,
304            #[cfg(feature = "native")]
305            Doc::Archive(a) => a,
306            #[cfg(feature = "native")]
307            Doc::ArchiveRaw(a) => a,
308            #[cfg(feature = "native")]
309            Doc::Xlsx(a) => a,
310            #[cfg(feature = "native")]
311            Doc::Syntax(a) => a,
312            #[cfg(feature = "native")]
313            Doc::Code(a) => a,
314            Doc::Mount(a) => a,
315            Doc::Boxed(a, _) => &*a.0,
316        }
317    }
318
319    /// Run `query` and render its results as exportable markup:
320    /// `md`/`markdown`, `html`, or `txt`/`text`. Node results render
321    /// structurally through the text vocabulary — sections back to
322    /// headings, lists to lists — and kinds outside it degrade to
323    /// prose paragraphs; value results render as lines.
324    pub fn export(
325        &self,
326        query: &str,
327        now: (i64, u32),
328        allow_shell: bool,
329        kind: &str,
330    ) -> Result<String> {
331        let render = quarb_text::Render::from_name(kind)
332            .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
333        // An empty query exports the whole document.
334        if query.trim().is_empty() {
335            let base = self.base_dyn();
336            return Ok(quarb_text::render_nodes(base, &[base.root()], render));
337        }
338        match self
339            .run(query, now, allow_shell)
340            .map_err(|e| anyhow::anyhow!("{e}"))?
341        {
342            QueryResult::Nodes(nodes) => {
343                Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
344            }
345            QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
346        }
347    }
348
349    /// Run against a `--model`-enriched view of this source: the
350    /// derived containers, references, and edges the model declares,
351    /// over this `Doc`'s base. `now` binds `now()` for the base and
352    /// its constructor queries alike.
353    pub fn run_modeled(
354        &self,
355        query: &str,
356        now: (i64, u32),
357        allow_shell: bool,
358        model: &quarb_model::Model,
359    ) -> quarb::Result<QueryResult> {
360        let (secs, nanos) = now;
361        let base = quarb_model::Borrowed(self.base_dyn());
362        let nowed = WithNow {
363            inner: &base,
364            secs,
365            nanos,
366        };
367        let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
368        if allow_shell {
369            quarb::run(query, &AllowShell { inner: &enriched })
370        } else {
371            quarb::run(query, &enriched)
372        }
373    }
374
375    /// Render a node from a model-enriched run: `/container/value`
376    /// for derived nodes, the base's own renderer otherwise.
377    pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
378        let enriched =
379            quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
380        enriched.locator(node, |bn| self.render(bn))
381    }
382
383    pub fn run(
384        &self,
385        query: &str,
386        now: (i64, u32),
387        allow_shell: bool,
388    ) -> quarb::Result<QueryResult> {
389        let (secs, nanos) = now;
390        macro_rules! go {
391            ($a:expr) => {{
392                let nowed = WithNow {
393                    inner: $a,
394                    secs,
395                    nanos,
396                };
397                if allow_shell {
398                    quarb::run(query, &AllowShell { inner: &nowed })
399                } else {
400                    quarb::run(query, &nowed)
401                }
402            }};
403        }
404        match self {
405            Doc::Json(a) => go!(a),
406            Doc::Csv(a) => go!(a),
407            Doc::Xml(a) => go!(a),
408            Doc::Html(a) => go!(a),
409            Doc::Text(a) => go!(a),
410            #[cfg(feature = "sqlite")]
411            Doc::Sqlite(a) => go!(a),
412            #[cfg(feature = "native")]
413            Doc::Fs(a) => go!(a),
414            #[cfg(feature = "native")]
415            Doc::FsDeep(a) => go!(a),
416            #[cfg(feature = "native")]
417            Doc::Git(a) => go!(a),
418            #[cfg(feature = "native")]
419            Doc::Archive(a) => go!(a),
420            #[cfg(feature = "native")]
421            Doc::ArchiveRaw(a) => go!(a),
422            #[cfg(feature = "native")]
423            Doc::Xlsx(a) => go!(a),
424            #[cfg(feature = "native")]
425            Doc::Syntax(a) => go!(a),
426            #[cfg(feature = "native")]
427            Doc::Code(a) => go!(a),
428            Doc::Mount(a) => go!(a),
429            Doc::Boxed(a, _) => go!(a),
430        }
431    }
432
433    /// Render a node result as its source-appropriate locator.
434    /// The adapter family this document is mounted through — the
435    /// `adapter` a trace payload's meta names.
436    pub fn family(&self) -> &'static str {
437        match self {
438            Doc::Json(_) => "json",
439            Doc::Csv(_) => "csv",
440            Doc::Xml(_) => "xml",
441            Doc::Html(_) => "html",
442            Doc::Text(_) => "text",
443            #[cfg(feature = "sqlite")]
444            Doc::Sqlite(_) => "sqlite",
445            #[cfg(feature = "native")]
446            Doc::Fs(_) | Doc::FsDeep(_) => "fs",
447            #[cfg(feature = "native")]
448            Doc::Git(_) => "git",
449            #[cfg(feature = "native")]
450            Doc::Archive(_) | Doc::ArchiveRaw(_) => "archive",
451            #[cfg(feature = "native")]
452            Doc::Xlsx(_) => "xlsx",
453            #[cfg(feature = "native")]
454            Doc::Syntax(_) => "tree-sitter",
455            #[cfg(feature = "native")]
456            Doc::Code(_) => "code",
457            Doc::Mount(_) => "mount",
458            Doc::Boxed(..) => "boxed",
459        }
460    }
461
462    /// The `quarb/trace` payload for `committed` (plus an uncommitted
463    /// `staged` suffix) over this document — every snapshot, thread,
464    /// ribbon, and lookahead verdict, windowed per `opts`. Locators
465    /// are this document's [`render`](Self::render).
466    pub fn trace(
467        &self,
468        committed: &str,
469        staged: Option<&str>,
470        opts: &quarb::trace::Options,
471    ) -> quarb::Result<quarb_trace::Payload> {
472        let base = quarb_model::Borrowed(self.base_dyn());
473        quarb::trace::trace(committed, staged, &base, &|n| self.render(n), opts)
474    }
475
476    pub fn render(&self, node: NodeId) -> String {
477        match self {
478            Doc::Json(a) => a.pointer(node),
479            Doc::Csv(a) => a.locator(node),
480            Doc::Xml(a) => a.locator(node),
481            Doc::Html(a) => a.locator(node),
482            Doc::Text(a) => a.locator(node),
483            #[cfg(feature = "sqlite")]
484            Doc::Sqlite(a) => a.locator(node),
485            #[cfg(feature = "native")]
486            Doc::Fs(a) => a.path(node).display().to_string(),
487            #[cfg(feature = "native")]
488            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
489            #[cfg(feature = "native")]
490            Doc::Git(a) => a.locator(node),
491            #[cfg(feature = "native")]
492            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
493            #[cfg(feature = "native")]
494            Doc::ArchiveRaw(a) => a.locator(node),
495            #[cfg(feature = "native")]
496            Doc::Xlsx(a) => a.locator(node),
497            #[cfg(feature = "native")]
498            Doc::Syntax(a) => a.locator(node),
499            #[cfg(feature = "native")]
500            Doc::Code(a) => a.locator(node),
501            Doc::Mount(a) => generic_locator(a, node),
502            Doc::Boxed(_, render) => render(node),
503        }
504    }
505
506    /// Open a SQLite database from its file bytes — a `.db` that
507    /// never touched a filesystem (the browser's uploaded files).
508    #[cfg(feature = "sqlite")]
509    pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
510        Ok(Doc::Sqlite(
511            quarb_sqlite::SqliteAdapter::from_bytes(bytes)
512                .map_err(|e| anyhow::anyhow!("{e}"))
513                .context("opening SQLite bytes")?,
514        ))
515    }
516
517    /// The refusal twin: the API is present either way, so a
518    /// consumer compiles against both builds and the absence
519    /// reports itself instead of failing to link.
520    #[cfg(not(feature = "sqlite"))]
521    pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
522        anyhow::bail!("this quarb-session was built without the `sqlite` feature")
523    }
524
525    /// Mount already-built documents as named children of one root —
526    /// the general wasm-safe mount, for callers that assembled their
527    /// `Doc`s from text or bytes rather than paths.
528    pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
529        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
530        for (name, doc) in parts {
531            if mounts.iter().any(|m| m.name == name) {
532                bail!("two sources mount as '{name}'; give each a distinct name");
533            }
534            mounts.push(quarb_mount::Mount {
535                name,
536                // Assembled from text/bytes: no real-world address to
537                // record — the mount name stands in for :::source.
538                target: None,
539                adapter: doc.into_boxed()?,
540            });
541        }
542        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
543    }
544
545    /// Declare a source's own URL on its adapter — today the html
546    /// DOM reading, whose relative hrefs join against it for the
547    /// acquisition arrow (`::href-->` across documents). Other
548    /// formats ignore it (their cross-references carry no URL yet).
549    pub fn attach_url(&mut self, url: &str) {
550        match self {
551            Doc::Html(a) => a.set_document_url(url),
552            Doc::Text(a) => a.set_document_url(url),
553            _ => {}
554        }
555    }
556
557    /// The reference targets for this doc: external-reference
558    /// identifier (its declared URL) → the root the arrow lands
559    /// on, for every mounted source that declared one. `urls` is
560    /// `(mount name, url)`; on a single-source (path-bare) doc the
561    /// one root answers.
562    pub fn url_roots(
563        &self,
564        urls: &[(String, String)],
565    ) -> std::collections::HashMap<String, quarb::NodeId> {
566        use quarb::AstAdapter;
567        let a = self.base_dyn();
568        let root = a.root();
569        match self {
570            Doc::Mount(_) => urls
571                .iter()
572                .filter_map(|(name, url)| {
573                    a.children_named(root, name)
574                        .first()
575                        .map(|&n| (url.clone(), n))
576                })
577                .collect(),
578            _ => urls
579                .iter()
580                .take(1)
581                .map(|(_, url)| (url.clone(), root))
582                .collect(),
583        }
584    }
585
586    /// Mount several already-parsed text documents as named children
587    /// of one root — [`Doc::mount_docs`] over [`Doc::parse`], for
588    /// callers that hold text (the browser playground's paste
589    /// boxes). `parts` is `(name, format, text)`.
590    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
591        let mut docs: Vec<(String, Doc)> = Vec::new();
592        for (name, format, text) in parts {
593            let doc = Doc::parse(text, format)
594                .with_context(|| format!("parsing '{name}' as {format}"))?;
595            docs.push((name.clone(), doc));
596        }
597        Doc::mount_docs(docs)
598    }
599
600    /// Box this source as a shared adapter — a mount child.
601    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
602        use quarb_mount::Shared;
603        Ok(match self {
604            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
605            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
606            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
607            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
608            Doc::Text(a) => Box::new(Shared(Rc::new(a))),
609            #[cfg(feature = "sqlite")]
610            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
611            #[cfg(feature = "native")]
612            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
613            #[cfg(feature = "native")]
614            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
615            #[cfg(feature = "native")]
616            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
617            #[cfg(feature = "native")]
618            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
619            #[cfg(feature = "native")]
620            Doc::ArchiveRaw(a) => Box::new(Shared(Rc::new(a))),
621            #[cfg(feature = "native")]
622            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
623            #[cfg(feature = "native")]
624            Doc::Syntax(a) => Box::new(Shared(Rc::new(a))),
625            #[cfg(feature = "native")]
626            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
627            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
628            Doc::Boxed(a, _) => a.0,
629        })
630    }
631}
632
633// ---------------------------------------------------------------------
634// Native-only: filesystem/db/git dispatch and multi-source mounts.
635// ---------------------------------------------------------------------
636
637#[cfg(feature = "native")]
638impl Doc {
639    /// Open one path as a local source. Directories are filesystem
640    /// trees (`--graft` grafts parseable leaves); `git:PATH` opens a
641    /// repository; binary kinds (SQLite, spreadsheets, archives) and
642    /// source files dispatch by extension/magic; everything else is a
643    /// text document parsed by extension or content sniff.
644    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
645        if path.is_dir() {
646            let fsopts = quarb_fs::FsOptions {
647                hidden: opts.hidden,
648                respect_ignore: opts.respect_ignore,
649            };
650            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
651                .with_context(|| format!("opening directory {}", path.display()))?;
652            return Ok(if opts.graft {
653                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
654                    fs,
655                    |fs, n| Some(fs.path(n)),
656                ))
657            } else {
658                Doc::Fs(fs)
659            });
660        }
661
662        let s = path.to_string_lossy();
663        if let Some(repo) = s.strip_prefix("git:") {
664            let a =
665                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
666            return Ok(Doc::Git(a));
667        }
668        // A `koine:` prefix takes the koine route to the same
669        // reader's model: the native atrep formats today, foreign
670        // formats once atrep grows import homs. Refusals name the
671        // native spelling.
672        if let Some(rest) = s.strip_prefix("koine:")
673            && !rest.is_empty()
674        {
675            // The house ?param syntax: ?format= forces a format.
676            let (path_part, format) = match rest.split_once('?') {
677                Some((p, q)) => {
678                    let mut fmt = None;
679                    for pair in q.split('&') {
680                        match pair.split_once('=') {
681                            Some(("format", v)) => fmt = Some(v.to_string()),
682                            _ => {
683                                return Err(anyhow::anyhow!(
684                                    "unknown koine option {pair:?} — supported: format="
685                                ));
686                            }
687                        }
688                    }
689                    (p, fmt)
690                }
691                None => (rest, None),
692            };
693            let target = Path::new(path_part);
694            let ext = target
695                .extension()
696                .and_then(|e| e.to_str())
697                .map(|e| e.to_ascii_lowercase());
698            let read = || -> Result<String> {
699                let text = std::fs::read_to_string(target)
700                    .with_context(|| format!("reading {}", target.display()))?;
701                Ok(text
702                    .strip_prefix('\u{feff}')
703                    .map(str::to_owned)
704                    .unwrap_or(text))
705            };
706            if let Some(fmt) = format {
707                let imported = match fmt.as_str() {
708                    "atd" | "atk" => {
709                        let model = quarb_text_koine::parse_file(target).with_context(|| {
710                            format!("reading {} as an atrep document", target.display())
711                        })?;
712                        return Ok(Doc::Text(model));
713                    }
714                    "md" | "markdown" => quarb_text_koine::parse_markdown(&read()?),
715                    "html" => quarb_text_koine::parse_html(&read()?),
716                    "rst" => quarb_text_koine::parse_rst(&read()?),
717                    "org" => quarb_text_koine::parse_org(&read()?),
718                    "dj" | "djot" => quarb_text_koine::parse_djot(&read()?),
719                    "tei" | "docbook" | "jats" | "usx" | "osis" => {
720                        quarb_text_koine::parse_xml_as(&read()?, &fmt)
721                    }
722                    other => {
723                        return Err(anyhow::anyhow!(
724                            "unknown koine format {other:?} — known: md, html, rst, org, djot, tei, docbook, jats, usx, osis, atd"
725                        ));
726                    }
727                };
728                return imported.map(Doc::Text).with_context(|| {
729                    format!("importing {} through atrep as {fmt}", target.display())
730                });
731            }
732            let imported = match ext.as_deref() {
733                Some("atd" | "atk") => {
734                    let model = quarb_text_koine::parse_file(target).with_context(|| {
735                        format!("reading {} as an atrep document", target.display())
736                    })?;
737                    return Ok(Doc::Text(model));
738                }
739                Some("bib") => quarb_text_koine::parse_bibtex(&read()?),
740                Some("md" | "markdown") => quarb_text_koine::parse_markdown(&read()?),
741                Some("html" | "htm") => quarb_text_koine::parse_html(&read()?),
742                Some("rst") => quarb_text_koine::parse_rst(&read()?),
743                Some("org") => quarb_text_koine::parse_org(&read()?),
744                Some("dj" | "djot") => quarb_text_koine::parse_djot(&read()?),
745                Some("xml") => {
746                    let text = read()?;
747                    match quarb_text_koine::detect_xml_kind(&text) {
748                        Some(kind) => quarb_text_koine::parse_xml_as(&text, kind),
749                        None => {
750                            return Err(anyhow::anyhow!(
751                                "{} declares no XML identity this route knows — force one with koine:{}?format=tei|docbook|jats|usx|osis",
752                                target.display(),
753                                target.display()
754                            ));
755                        }
756                    }
757                }
758                Some("pdf") => {
759                    return Err(anyhow::anyhow!(
760                        "atrep cannot import a PDF — the print reading is text:{}",
761                        target.display()
762                    ));
763                }
764                _ => {
765                    return Err(anyhow::anyhow!(
766                        "no atrep import for this format yet — the native reading is text:{}",
767                        target.display()
768                    ));
769                }
770            };
771            return imported
772                .map(Doc::Text)
773                .with_context(|| format!("importing {} through atrep", target.display()));
774        }
775        // A `text:` prefix forces the text-level reading, matching
776        // qua's dispatch: producer by extension, `<` sniffing
777        // markup, plain paragraphs as the fallback.
778        if let Some(rest) = s.strip_prefix("text:")
779            && !rest.is_empty()
780        {
781            let target = Path::new(rest);
782            // The binary producers dispatch before the text read:
783            // a .docx or .epub is a zip, a .pdf its own format.
784            let ext = target
785                .extension()
786                .and_then(|e| e.to_str())
787                .map(|e| e.to_ascii_lowercase());
788            // Native atrep files converge on the koine route.
789            if let Some("atd" | "atk") = ext.as_deref() {
790                let model = quarb_text_koine::parse_file(target).with_context(|| {
791                    format!("reading {} as an atrep document", target.display())
792                })?;
793                return Ok(Doc::Text(model));
794            }
795            if ext.as_deref() == Some("pdf") {
796                let bytes = std::fs::read(target)
797                    .with_context(|| format!("reading {}", target.display()))?;
798                let model = quarb_text_pdf::parse(&bytes)
799                    .with_context(|| format!("reading {} as PDF", target.display()))?;
800                let a = std::rc::Rc::new(model);
801                let r = a.clone();
802                return Ok(Doc::Boxed(
803                    Dyn(Box::new(quarb_mount::Shared(a))),
804                    Box::new(move |n| r.locator(n)),
805                ));
806            }
807            if let Some(ext @ ("docx" | "epub")) = ext.as_deref() {
808                let bytes = std::fs::read(target)
809                    .with_context(|| format!("reading {}", target.display()))?;
810                let model = if ext == "docx" {
811                    quarb_text_docx::parse(&bytes)
812                        .with_context(|| format!("reading {} as Word", target.display()))?
813                } else {
814                    quarb_text_epub::parse(&bytes)
815                        .with_context(|| format!("reading {} as EPUB", target.display()))?
816                };
817                return Ok(Doc::Text(model));
818            }
819            let text = std::fs::read_to_string(target)
820                .with_context(|| format!("reading {}", target.display()))?;
821            let text = text
822                .strip_prefix('\u{feff}')
823                .map(str::to_owned)
824                .unwrap_or(text);
825            let format = match target
826                .extension()
827                .and_then(|e| e.to_str())
828                .map(|e| e.to_ascii_lowercase())
829                .as_deref()
830            {
831                Some("html" | "htm") => "text-html",
832                Some("md" | "markdown") => "text-markdown",
833                Some("tex" | "latex") => "tex",
834                Some("txt") => "text",
835                #[cfg(feature = "koine")]
836                Some("bib") => "text-bibtex",
837                _ if text.trim_start().starts_with('<') => "text-html",
838                _ => "text",
839            };
840            return Doc::parse(&text, format);
841        }
842        // A `code:` prefix forces the code-level reading,
843        // matching qua's dispatch; a directory mounts the
844        // composed, grafted view with source leaves grafted
845        // at the code level.
846        if let Some(rest) = s.strip_prefix("code:")
847            && !rest.is_empty()
848        {
849            if opts.no_graft {
850                bail!(
851                    "no_graft refuses the code: prefix: the prefix's whole \
852                     meaning is the grafted code-level view"
853                );
854            }
855            let target = Path::new(rest);
856            if target.is_dir() {
857                let fsopts = quarb_fs::FsOptions {
858                    hidden: opts.hidden,
859                    respect_ignore: opts.respect_ignore,
860                };
861                let fs = quarb_fs::FsAdapter::with_options(target, fsopts)
862                    .with_context(|| format!("opening directory {}", target.display()))?;
863                return Ok(Doc::FsDeep(
864                    quarb_compose::ComposeAdapter::with_source_paths(fs, |fs, n| Some(fs.path(n)))
865                        .with_source_graft(quarb_compose::SourceGraft::Code),
866                ));
867            }
868            let a = quarb_code::CodeModel::open(target)
869                .with_context(|| format!("parsing {} at the code level", target.display()))?;
870            return Ok(Doc::Code(a));
871        }
872
873        let ext = path
874            .extension()
875            .and_then(|e| e.to_str())
876            .map(|e| e.to_ascii_lowercase());
877
878        if let Some(e) = &ext
879            && quarb_tree_sitter::supported(e)
880        {
881            let a =
882                quarb_tree_sitter::TreeSitterAdapter::open(path).context("parsing source file")?;
883            return Ok(Doc::Syntax(a));
884        }
885        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
886            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
887            return Ok(Doc::Xlsx(a));
888        }
889        if is_sqlite(path) {
890            // Refuse, never fall through: a .db is binary, and
891            // letting it reach the text sniffers would trade a
892            // clear absence for a confusing parse error.
893            #[cfg(not(feature = "sqlite"))]
894            anyhow::bail!(
895                "{}: this quarb-session was built without the `sqlite` feature",
896                path.display()
897            );
898            #[cfg(feature = "sqlite")]
899            {
900                let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
901                    .context("opening SQLite database")?;
902                return Ok(Doc::Sqlite(a));
903            }
904        }
905        if is_archive(path) {
906            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
907            return Ok(if opts.no_graft {
908                Doc::ArchiveRaw(a)
909            } else {
910                Doc::Archive(quarb_compose::ComposeAdapter::new(a))
911            });
912        }
913
914        // Text documents.
915        let text =
916            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
917        let text = text
918            .strip_prefix('\u{feff}')
919            .map(str::to_owned)
920            .unwrap_or(text);
921        match ext.as_deref() {
922            Some("csv") => Doc::parse(&text, "csv"),
923            Some("tsv") => Doc::parse(&text, "tsv"),
924            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
925            Some("toml") => Doc::parse(&text, "toml"),
926            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
927            Some("txt") => Doc::parse(&text, "text"),
928            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
929            _ => {
930                if is_xml(path, &text) {
931                    Doc::parse(&text, "xml")
932                } else if is_html(path, &text) {
933                    Doc::parse(&text, "html")
934                } else {
935                    Doc::parse(&text, "json")
936                }
937            }
938        }
939    }
940
941    /// Open several sources as named children of one root (file stem =
942    /// mount name), so a single query — including a `<=>` join — spans
943    /// them all.
944    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
945        let specs: Vec<crate::MountSpec> = paths
946            .iter()
947            .map(|p| crate::MountSpec {
948                name: None,
949                path: p.clone(),
950            })
951            .collect();
952        Doc::mount_specs(&specs, opts)
953    }
954
955    /// [`Doc::mount`] with optional explicit mount names
956    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
957    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
958        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
959        for (i, spec) in specs.iter().enumerate() {
960            let name = spec.name.clone().unwrap_or_else(|| {
961                spec.path
962                    .file_stem()
963                    .map(|s| s.to_string_lossy().into_owned())
964                    .unwrap_or_else(|| format!("doc{i}"))
965            });
966            if mounts.iter().any(|m| m.name == name) {
967                bail!(
968                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
969                     same name; give each a distinct basename (or a NAME=TARGET alias)",
970                    spec.path.display()
971                );
972            }
973            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
974            mounts.push(quarb_mount::Mount {
975                name,
976                target: Some(spec.path.display().to_string()),
977                adapter,
978            });
979        }
980        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
981    }
982}
983
984/// A name-path locator built from the adapter trait alone
985/// (`parent`/`name`) — used for a mount, whose per-source render
986/// functions we do not keep.
987fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
988    let mut parts = Vec::new();
989    let mut cur = Some(node);
990    while let Some(n) = cur {
991        if let Some(nm) = a.name(n) {
992            parts.push(nm);
993        }
994        cur = a.parent(n);
995    }
996    parts.reverse();
997    format!("/{}", parts.join("/"))
998}
999
1000/// Whether a file is a SQLite database — by extension, or the 16-byte
1001/// header magic.
1002#[cfg(feature = "native")]
1003fn is_sqlite(path: &Path) -> bool {
1004    if path
1005        .extension()
1006        .and_then(|e| e.to_str())
1007        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
1008    {
1009        return true;
1010    }
1011    use std::io::Read as _;
1012    let mut buf = [0u8; 16];
1013    std::fs::File::open(path)
1014        .and_then(|mut f| f.read_exact(&mut buf))
1015        .is_ok()
1016        && &buf == b"SQLite format 3\0"
1017}
1018
1019/// Whether a file is an archive — by extension, or zip/gzip magic.
1020#[cfg(feature = "native")]
1021fn is_archive(path: &Path) -> bool {
1022    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
1023        matches!(
1024            e.to_ascii_lowercase().as_str(),
1025            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
1026        )
1027    }) {
1028        return true;
1029    }
1030    use std::io::Read as _;
1031    let mut buf = [0u8; 2];
1032    std::fs::File::open(path)
1033        .and_then(|mut f| f.read_exact(&mut buf))
1034        .is_ok()
1035        && (&buf == b"PK" || buf == [0x1f, 0x8b])
1036}
1037
1038/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
1039/// `<?xml` prolog.
1040#[cfg(feature = "native")]
1041fn is_xml(path: &Path, text: &str) -> bool {
1042    path.extension()
1043        .and_then(|e| e.to_str())
1044        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
1045        || text.trim_start().starts_with("<?xml")
1046}
1047
1048/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
1049/// starts with `<`.
1050#[cfg(feature = "native")]
1051fn is_html(path: &Path, text: &str) -> bool {
1052    path.extension()
1053        .and_then(|e| e.to_str())
1054        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
1055        || text.trim_start().starts_with('<')
1056}