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