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    /// The shell gate (qua's --allow-shell): `corpus:…?annotate=CMD`
37    /// runs a command only under it.
38    pub allow_shell: bool,
39    /// The session's sentence bonds (a `.desm` set), for the corpus
40    /// reading; `?desm=FILE` on a mount adds to them.
41    pub bonds: Option<Rc<syndesmos::Syndesmos>>,
42}
43
44/// A materialized source: one variant per adapter family. JSON-model
45/// formats (json/yaml/toml) render node results as pointers, the rest
46/// as locators.
47pub enum Doc {
48    Json(quarb_json::JsonAdapter),
49    Csv(quarb_csv::CsvAdapter),
50    Xml(quarb_xml::XmlAdapter),
51    Html(quarb_html::HtmlAdapter),
52    Text(quarb_text::TextModel),
53    #[cfg(feature = "sqlite")]
54    Sqlite(quarb_sqlite::SqliteAdapter),
55    #[cfg(feature = "native")]
56    Fs(quarb_fs::FsAdapter),
57    #[cfg(feature = "native")]
58    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
59    #[cfg(feature = "native")]
60    Git(quarb_git::GitAdapter),
61    #[cfg(feature = "native")]
62    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
63    /// An archive held opaque (`no_graft`): the member tree with
64    /// leaves as plain entries — the tar -t view.
65    #[cfg(feature = "native")]
66    ArchiveRaw(quarb_archive::ArchiveAdapter),
67    #[cfg(feature = "native")]
68    Xlsx(quarb_xlsx::XlsxAdapter),
69    #[cfg(feature = "native")]
70    Syntax(quarb_tree_sitter::TreeSitterAdapter),
71    #[cfg(feature = "native")]
72    Code(quarb_code::CodeModel),
73    Mount(quarb_mount::MountAdapter),
74    /// Any adapter behind the object-safe trait, with its locator
75    /// renderer — the carrier for scheme targets opened through
76    /// qua's dispatch (`gcl:`, `kafka:`, `neo4j://`, …).
77    Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
78}
79
80/// A boxed adapter as an adapter — plain delegation (the
81/// quarb-py `Dyn` pattern).
82pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
83
84impl quarb::AstAdapter for Dyn {
85    fn root(&self) -> NodeId {
86        self.0.root()
87    }
88    fn children(&self, node: NodeId) -> Vec<NodeId> {
89        self.0.children(node)
90    }
91    fn name(&self, node: NodeId) -> Option<String> {
92        self.0.name(node)
93    }
94    fn parent(&self, node: NodeId) -> Option<NodeId> {
95        self.0.parent(node)
96    }
97    fn traits(&self, node: NodeId) -> Vec<String> {
98        self.0.traits(node)
99    }
100    fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
101        self.0.property(node, name)
102    }
103    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
104        self.0.children_named(node, name)
105    }
106    fn child_host(&self, node: NodeId) -> NodeId {
107        self.0.child_host(node)
108    }
109    fn non_tree_nodes(&self) -> Vec<NodeId> {
110        self.0.non_tree_nodes()
111    }
112    fn properties(&self, node: NodeId) -> Vec<(String, quarb::Value)> {
113        self.0.properties(node)
114    }
115    fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
116        self.0.default_value(node)
117    }
118    fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
119        self.0.metadata(node, key)
120    }
121    fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
122        self.0.aliased_metadata(node)
123    }
124    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
125        self.0.links(node)
126    }
127    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
128        self.0.backlinks(node)
129    }
130    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
131        self.0.resolve(node, property, hint)
132    }
133    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
134        self.0.external_ref(node, property, hint)
135    }
136    fn resolve_fragment(&self, node: NodeId, fragment: &str) -> Option<NodeId> {
137        self.0.resolve_fragment(node, fragment)
138    }
139    fn ref_property(&self, node: NodeId) -> Option<String> {
140        self.0.ref_property(node)
141    }
142    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
143        self.0.ref_label(node, property)
144    }
145    fn link_property(
146        &self,
147        source: NodeId,
148        label: &str,
149        target: NodeId,
150        name: &str,
151    ) -> Option<quarb::Value> {
152        self.0.link_property(source, label, target, name)
153    }
154    fn quantifier_bound(&self) -> usize {
155        self.0.quantifier_bound()
156    }
157    fn invocation_instant(&self) -> Option<(i64, u32)> {
158        self.0.invocation_instant()
159    }
160    fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
161        self.0.unit_scale(expr)
162    }
163    // The rest of the surface, forwarded too: an adapter behind
164    // this door keeps its own answers to names and traits (a web
165    // level's rows answer to their kind), its index hooks, its
166    // provenance, and its shell gate.
167    fn answers_to(&self, node: NodeId, name: &str) -> bool {
168        self.0.answers_to(node, name)
169    }
170    fn has_trait(&self, node: NodeId, name: &str) -> bool {
171        self.0.has_trait(node, name)
172    }
173    fn has_node(&self, node: NodeId) -> bool {
174        self.0.has_node(node)
175    }
176    fn document_by_ref(&self, id: &str) -> Option<NodeId> {
177        self.0.document_by_ref(id)
178    }
179    fn reverse_resolve(
180        &self,
181        node: NodeId,
182        property: Option<&str>,
183        hint: Option<&str>,
184    ) -> Option<Vec<NodeId>> {
185        self.0.reverse_resolve(node, property, hint)
186    }
187    fn descendants_named(&self, node: NodeId, name: &str) -> Option<Vec<(NodeId, usize)>> {
188        self.0.descendants_named(node, name)
189    }
190    fn prefetch_links(&self, nodes: &[NodeId], dir: quarb::LinkDir) {
191        self.0.prefetch_links(nodes, dir)
192    }
193    fn provenance(&self, node: NodeId) -> quarb::Provenance {
194        self.0.provenance(node)
195    }
196    fn provenance_list(&self, node: NodeId) -> quarb::ProvenanceList {
197        self.0.provenance_list(node)
198    }
199    fn allow_shell(&self) -> bool {
200        self.0.allow_shell()
201    }
202}
203
204impl Doc {
205    /// The kaiv door: the adapter is Rc-shared between the Doc and
206    /// its locator renderer (the `Shared` pattern qua's scheme
207    /// mounts use), riding the Boxed variant.
208    fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
209        let a = std::rc::Rc::new(a);
210        let r = a.clone();
211        Doc::Boxed(
212            Dyn(Box::new(quarb_mount::Shared(a))),
213            Box::new(move |n| r.locator(n)),
214        )
215    }
216
217    /// Parse a text document by format name — the wasm entry point,
218    /// and the text tail of the native `open`. Formats: json, yaml,
219    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson, kaiv/daiv.
220    pub fn parse(input: &str, format: &str) -> Result<Doc> {
221        match format {
222            // kaiv rides the Boxed door (no dedicated variant): the
223            // offline resolver — a browser mount has no filesystem
224            // or registry, so `.!units`/`.!types` imports beyond the
225            // embedded core fail with kaiv's own pointed error.
226            "kaiv" => {
227                let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
228                    .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
229                return Ok(Self::boxed_kaiv(a));
230            }
231            "daiv" => {
232                let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
233                    .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
234                return Ok(Self::boxed_kaiv(a));
235            }
236            "json" => quarb_json::JsonAdapter::parse(input)
237                .map(Doc::Json)
238                .context("parsing JSON"),
239            // The property reading: containers are nodes, scalars
240            // are properties (json2:FILE in qua; the format name for
241            // a pasted document).
242            "json2" => quarb_json::JsonAdapter::parse_as(input, quarb_json::Reading::Property)
243                .map(Doc::Json)
244                .context("parsing JSON"),
245            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
246                .map(Doc::Json)
247                .context("parsing JSONL"),
248            "yaml" | "yml" => quarb_yaml::parse(input)
249                .map(Doc::Json)
250                .context("parsing YAML"),
251            "toml" => quarb_toml::parse(input)
252                .map(Doc::Json)
253                .context("parsing TOML"),
254            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
255                .map(Doc::Csv)
256                .context("parsing CSV"),
257            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
258                .map(Doc::Csv)
259                .context("parsing TSV"),
260            "xml" => quarb_xml::XmlAdapter::parse(input)
261                .map(Doc::Xml)
262                .context("parsing XML"),
263            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
264            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
265            // LaTeX source, through the text-level producer — a
266            // pure text parser, so the wasm build carries it too.
267            "tex" | "latex" => Ok(Doc::Text(quarb_text_latex::parse(input))),
268            // The text level: the shared section/paragraph
269            // vocabulary, produced per source format ("text" is
270            // plain text — blank-line paragraphs).
271            "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
272            "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
273            "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
274            // A treebank (ruling #63): as a document, and as a corpus
275            // with its own tokens.
276            "conllu" => quarb_text::TextModel::parse_conllu_text(input)
277                .map(Doc::Text)
278                .map_err(|e| anyhow::anyhow!("reading CoNLL-U: {e}")),
279            "corpus-conllu" => quarb_text::TextModel::parse_conllu_corpus(input)
280                .map(Doc::Text)
281                .map_err(|e| anyhow::anyhow!("reading CoNLL-U: {e}")),
282            // The corpus reading: the same three, tokenized.
283            "corpus-html" | "corpus-markdown" | "corpus" => {
284                let mut model = match format {
285                    "corpus-html" => quarb_text_html::parse(input),
286                    "corpus-markdown" => quarb_text_markdown::parse(input),
287                    _ => quarb_text::TextModel::parse_plain(input),
288                };
289                model.tokenize();
290                Ok(Doc::Text(model))
291            }
292            // BibTeX / BibLaTeX, imported through atrep's
293            // bibliogramma: a document of bib entries.
294            #[cfg(feature = "koine")]
295            "text-bibtex" | "bibtex" => quarb_text_koine::parse_bibtex(input)
296                .map(Doc::Text)
297                .map_err(|e| anyhow::anyhow!("{e}")),
298            // The code level: identifiers as names; the format
299            // name carries the language.
300            #[cfg(feature = "native")]
301            "code-rust" => quarb_code::CodeModel::parse(input, "rs")
302                .map(Doc::Code)
303                .context("parsing Rust at the code level"),
304            #[cfg(feature = "native")]
305            "code-python" => quarb_code::CodeModel::parse(input, "py")
306                .map(Doc::Code)
307                .context("parsing Python at the code level"),
308            #[cfg(feature = "native")]
309            "code-javascript" => quarb_code::CodeModel::parse(input, "js")
310                .map(Doc::Code)
311                .context("parsing JavaScript at the code level"),
312            #[cfg(feature = "native")]
313            "code-c" => quarb_code::CodeModel::parse(input, "c")
314                .map(Doc::Code)
315                .context("parsing C at the code level"),
316            other => bail!("unknown format: {other}"),
317        }
318    }
319
320    /// Run one query against this source with the session's invocation
321    /// instant and shell permission. The query text carries any macro
322    /// definitions inline (the session prepends its table), which
323    /// `quarb::run` expands.
324    /// The concrete adapter behind this `Doc`, as `&dyn` — the base a
325    /// `--model` enrichment layer wraps (one match, so the model
326    /// paths avoid duplicating the variant arms). Wasm-safe; the
327    /// native-only variants are compiled in only under `native`.
328    pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
329        match self {
330            Doc::Json(a) => a,
331            Doc::Csv(a) => a,
332            Doc::Xml(a) => a,
333            Doc::Html(a) => a,
334            Doc::Text(a) => a,
335            #[cfg(feature = "sqlite")]
336            Doc::Sqlite(a) => a,
337            #[cfg(feature = "native")]
338            Doc::Fs(a) => a,
339            #[cfg(feature = "native")]
340            Doc::FsDeep(a) => a,
341            #[cfg(feature = "native")]
342            Doc::Git(a) => a,
343            #[cfg(feature = "native")]
344            Doc::Archive(a) => a,
345            #[cfg(feature = "native")]
346            Doc::ArchiveRaw(a) => a,
347            #[cfg(feature = "native")]
348            Doc::Xlsx(a) => a,
349            #[cfg(feature = "native")]
350            Doc::Syntax(a) => a,
351            #[cfg(feature = "native")]
352            Doc::Code(a) => a,
353            Doc::Mount(a) => a,
354            Doc::Boxed(a, _) => &*a.0,
355        }
356    }
357
358    /// Run `query` and render its results as exportable markup:
359    /// `md`/`markdown`, `html`, or `txt`/`text`. Node results render
360    /// structurally through the text vocabulary — sections back to
361    /// headings, lists to lists — and kinds outside it degrade to
362    /// prose paragraphs; value results render as lines.
363    pub fn export(
364        &self,
365        query: &str,
366        now: (i64, u32),
367        allow_shell: bool,
368        kind: &str,
369    ) -> Result<String> {
370        let render = quarb_text::Render::from_name(kind)
371            .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
372        // An empty query exports the whole document.
373        if query.trim().is_empty() {
374            let base = self.base_dyn();
375            return Ok(quarb_text::render_nodes(base, &[base.root()], render));
376        }
377        match self
378            .run(query, now, allow_shell)
379            .map_err(|e| anyhow::anyhow!("{e}"))?
380        {
381            QueryResult::Nodes(nodes) => {
382                Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
383            }
384            QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
385        }
386    }
387
388    /// Run against a `--model`-enriched view of this source: the
389    /// derived containers, references, and edges the model declares,
390    /// over this `Doc`'s base. `now` binds `now()` for the base and
391    /// its constructor queries alike.
392    pub fn run_modeled(
393        &self,
394        query: &str,
395        now: (i64, u32),
396        allow_shell: bool,
397        model: &quarb_model::Model,
398    ) -> quarb::Result<QueryResult> {
399        let (secs, nanos) = now;
400        let base = quarb_model::Borrowed(self.base_dyn());
401        let nowed = WithNow {
402            inner: &base,
403            secs,
404            nanos,
405        };
406        let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
407        enriched
408            .check_declared_traits()
409            .map_err(|e| quarb::QuarbError::Parse(format!("reading the model: {e}")))?;
410        if allow_shell {
411            quarb::run(query, &AllowShell { inner: &enriched })
412        } else {
413            quarb::run(query, &enriched)
414        }
415    }
416
417    /// Render a node from a model-enriched run: `/container/value`
418    /// for derived nodes, the base's own renderer otherwise.
419    pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
420        let enriched =
421            quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
422        enriched.locator(node, |bn| self.render(bn))
423    }
424
425    pub fn run(
426        &self,
427        query: &str,
428        now: (i64, u32),
429        allow_shell: bool,
430    ) -> quarb::Result<QueryResult> {
431        let (secs, nanos) = now;
432        macro_rules! go {
433            ($a:expr) => {{
434                let nowed = WithNow {
435                    inner: $a,
436                    secs,
437                    nanos,
438                };
439                if allow_shell {
440                    quarb::run(query, &AllowShell { inner: &nowed })
441                } else {
442                    quarb::run(query, &nowed)
443                }
444            }};
445        }
446        match self {
447            Doc::Json(a) => go!(a),
448            Doc::Csv(a) => go!(a),
449            Doc::Xml(a) => go!(a),
450            Doc::Html(a) => go!(a),
451            Doc::Text(a) => go!(a),
452            #[cfg(feature = "sqlite")]
453            Doc::Sqlite(a) => go!(a),
454            #[cfg(feature = "native")]
455            Doc::Fs(a) => go!(a),
456            #[cfg(feature = "native")]
457            Doc::FsDeep(a) => go!(a),
458            #[cfg(feature = "native")]
459            Doc::Git(a) => go!(a),
460            #[cfg(feature = "native")]
461            Doc::Archive(a) => go!(a),
462            #[cfg(feature = "native")]
463            Doc::ArchiveRaw(a) => go!(a),
464            #[cfg(feature = "native")]
465            Doc::Xlsx(a) => go!(a),
466            #[cfg(feature = "native")]
467            Doc::Syntax(a) => go!(a),
468            #[cfg(feature = "native")]
469            Doc::Code(a) => go!(a),
470            Doc::Mount(a) => go!(a),
471            Doc::Boxed(a, _) => go!(a),
472        }
473    }
474
475    /// Render a node result as its source-appropriate locator.
476    /// The adapter family this document is mounted through — the
477    /// `adapter` a trace payload's meta names.
478    pub fn family(&self) -> &'static str {
479        match self {
480            Doc::Json(_) => "json",
481            Doc::Csv(_) => "csv",
482            Doc::Xml(_) => "xml",
483            Doc::Html(_) => "html",
484            Doc::Text(_) => "text",
485            #[cfg(feature = "sqlite")]
486            Doc::Sqlite(_) => "sqlite",
487            #[cfg(feature = "native")]
488            Doc::Fs(_) | Doc::FsDeep(_) => "fs",
489            #[cfg(feature = "native")]
490            Doc::Git(_) => "git",
491            #[cfg(feature = "native")]
492            Doc::Archive(_) | Doc::ArchiveRaw(_) => "archive",
493            #[cfg(feature = "native")]
494            Doc::Xlsx(_) => "xlsx",
495            #[cfg(feature = "native")]
496            Doc::Syntax(_) => "tree-sitter",
497            #[cfg(feature = "native")]
498            Doc::Code(_) => "code",
499            Doc::Mount(_) => "mount",
500            Doc::Boxed(..) => "boxed",
501        }
502    }
503
504    /// The `quarb/trace` payload for `committed` (plus an uncommitted
505    /// `staged` suffix) over this document — every snapshot, thread,
506    /// ribbon, and lookahead verdict, windowed per `opts`. Locators
507    /// are this document's [`render`](Self::render).
508    pub fn trace(
509        &self,
510        committed: &str,
511        staged: Option<&str>,
512        opts: &quarb::trace::Options,
513    ) -> quarb::Result<quarb_trace::Payload> {
514        let base = quarb_model::Borrowed(self.base_dyn());
515        quarb::trace::trace(committed, staged, &base, &|n| self.render(n), opts)
516    }
517
518    pub fn render(&self, node: NodeId) -> String {
519        match self {
520            Doc::Json(a) => a.pointer(node),
521            Doc::Csv(a) => a.locator(node),
522            Doc::Xml(a) => a.locator(node),
523            Doc::Html(a) => a.locator(node),
524            Doc::Text(a) => a.locator(node),
525            #[cfg(feature = "sqlite")]
526            Doc::Sqlite(a) => a.locator(node),
527            #[cfg(feature = "native")]
528            Doc::Fs(a) => a.path(node).display().to_string(),
529            #[cfg(feature = "native")]
530            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
531            #[cfg(feature = "native")]
532            Doc::Git(a) => a.locator(node),
533            #[cfg(feature = "native")]
534            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
535            #[cfg(feature = "native")]
536            Doc::ArchiveRaw(a) => a.locator(node),
537            #[cfg(feature = "native")]
538            Doc::Xlsx(a) => a.locator(node),
539            #[cfg(feature = "native")]
540            Doc::Syntax(a) => a.locator(node),
541            #[cfg(feature = "native")]
542            Doc::Code(a) => a.locator(node),
543            Doc::Mount(a) => generic_locator(a, node),
544            Doc::Boxed(_, render) => render(node),
545        }
546    }
547
548    /// Open a SQLite database from its file bytes — a `.db` that
549    /// never touched a filesystem (the browser's uploaded files).
550    #[cfg(feature = "sqlite")]
551    pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
552        Ok(Doc::Sqlite(
553            quarb_sqlite::SqliteAdapter::from_bytes(bytes)
554                .map_err(|e| anyhow::anyhow!("{e}"))
555                .context("opening SQLite bytes")?,
556        ))
557    }
558
559    /// The refusal twin: the API is present either way, so a
560    /// consumer compiles against both builds and the absence
561    /// reports itself instead of failing to link.
562    #[cfg(not(feature = "sqlite"))]
563    pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
564        anyhow::bail!("this quarb-session was built without the `sqlite` feature")
565    }
566
567    /// Mount already-built documents as named children of one root —
568    /// the general wasm-safe mount, for callers that assembled their
569    /// `Doc`s from text or bytes rather than paths.
570    pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
571        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
572        for (name, doc) in parts {
573            if mounts.iter().any(|m| m.name == name) {
574                bail!("two sources mount as '{name}'; give each a distinct name");
575            }
576            mounts.push(quarb_mount::Mount {
577                name,
578                // Assembled from text/bytes: no real-world address to
579                // record — the mount name stands in for :::source.
580                target: None,
581                adapter: doc.into_boxed()?,
582            });
583        }
584        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
585    }
586
587    /// Declare a source's own URL on its adapter — today the html
588    /// DOM reading, whose relative hrefs join against it for the
589    /// acquisition arrow (`::href-->` across documents). Other
590    /// formats ignore it (their cross-references carry no URL yet).
591    pub fn attach_url(&mut self, url: &str) {
592        match self {
593            Doc::Html(a) => a.set_document_url(url),
594            Doc::Text(a) => a.set_document_url(url),
595            _ => {}
596        }
597    }
598
599    /// The reference targets for this doc: external-reference
600    /// identifier (its declared URL) → the root the arrow lands
601    /// on, for every mounted source that declared one. `urls` is
602    /// `(mount name, url)`; on a single-source (path-bare) doc the
603    /// one root answers.
604    pub fn url_roots(
605        &self,
606        urls: &[(String, String)],
607    ) -> std::collections::HashMap<String, quarb::NodeId> {
608        use quarb::AstAdapter;
609        let a = self.base_dyn();
610        let root = a.root();
611        match self {
612            Doc::Mount(_) => urls
613                .iter()
614                .filter_map(|(name, url)| {
615                    a.children_named(root, name)
616                        .first()
617                        .map(|&n| (url.clone(), n))
618                })
619                .collect(),
620            _ => urls
621                .iter()
622                .take(1)
623                .map(|(_, url)| (url.clone(), root))
624                .collect(),
625        }
626    }
627
628    /// Mount several already-parsed text documents as named children
629    /// of one root — [`Doc::mount_docs`] over [`Doc::parse`], for
630    /// callers that hold text (the browser playground's paste
631    /// boxes). `parts` is `(name, format, text)`.
632    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
633        let mut docs: Vec<(String, Doc)> = Vec::new();
634        for (name, format, text) in parts {
635            let doc = Doc::parse(text, format)
636                .with_context(|| format!("parsing '{name}' as {format}"))?;
637            docs.push((name.clone(), doc));
638        }
639        Doc::mount_docs(docs)
640    }
641
642    /// Box this source as a shared adapter — a mount child.
643    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
644        use quarb_mount::Shared;
645        Ok(match self {
646            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
647            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
648            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
649            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
650            Doc::Text(a) => Box::new(Shared(Rc::new(a))),
651            #[cfg(feature = "sqlite")]
652            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
653            #[cfg(feature = "native")]
654            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
655            #[cfg(feature = "native")]
656            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
657            #[cfg(feature = "native")]
658            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
659            #[cfg(feature = "native")]
660            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
661            #[cfg(feature = "native")]
662            Doc::ArchiveRaw(a) => Box::new(Shared(Rc::new(a))),
663            #[cfg(feature = "native")]
664            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
665            #[cfg(feature = "native")]
666            Doc::Syntax(a) => Box::new(Shared(Rc::new(a))),
667            #[cfg(feature = "native")]
668            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
669            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
670            Doc::Boxed(a, _) => a.0,
671        })
672    }
673}
674
675// ---------------------------------------------------------------------
676// Native-only: filesystem/db/git dispatch and multi-source mounts.
677// ---------------------------------------------------------------------
678
679#[cfg(feature = "native")]
680impl Doc {
681    /// Open one path as a local source. Directories are filesystem
682    /// trees (`--graft` grafts parseable leaves); `git:PATH` opens a
683    /// repository; binary kinds (SQLite, spreadsheets, archives) and
684    /// source files dispatch by extension/magic; everything else is a
685    /// text document parsed by extension or content sniff.
686    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
687        if path.is_dir() {
688            let fsopts = quarb_fs::FsOptions {
689                hidden: opts.hidden,
690                respect_ignore: opts.respect_ignore,
691            };
692            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
693                .with_context(|| format!("opening directory {}", path.display()))?;
694            return Ok(if opts.graft {
695                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
696                    fs,
697                    |fs, n| Some(fs.path(n)),
698                ))
699            } else {
700                Doc::Fs(fs)
701            });
702        }
703
704        let s = path.to_string_lossy();
705        if let Some(repo) = s.strip_prefix("git:") {
706            let a =
707                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
708            return Ok(Doc::Git(a));
709        }
710        // A `koine:` prefix takes the koine route to the same
711        // reader's model: the native atrep formats today, foreign
712        // formats once atrep grows import homs. Refusals name the
713        // native spelling.
714        if let Some(rest) = s.strip_prefix("koine:")
715            && !rest.is_empty()
716        {
717            // The house ?param syntax: ?format= forces a format.
718            let (path_part, format) = match rest.split_once('?') {
719                Some((p, q)) => {
720                    let mut fmt = None;
721                    for pair in q.split('&') {
722                        match pair.split_once('=') {
723                            Some(("format", v)) => fmt = Some(v.to_string()),
724                            _ => {
725                                return Err(anyhow::anyhow!(
726                                    "unknown koine option {pair:?} — supported: format="
727                                ));
728                            }
729                        }
730                    }
731                    (p, fmt)
732                }
733                None => (rest, None),
734            };
735            let target = Path::new(path_part);
736            let ext = target
737                .extension()
738                .and_then(|e| e.to_str())
739                .map(|e| e.to_ascii_lowercase());
740            let read = || -> Result<String> {
741                let text = std::fs::read_to_string(target)
742                    .with_context(|| format!("reading {}", target.display()))?;
743                Ok(text
744                    .strip_prefix('\u{feff}')
745                    .map(str::to_owned)
746                    .unwrap_or(text))
747            };
748            if let Some(fmt) = format {
749                let imported = match fmt.as_str() {
750                    "atd" | "atk" => {
751                        let model = quarb_text_koine::parse_file(target).with_context(|| {
752                            format!("reading {} as an atrep document", target.display())
753                        })?;
754                        return Ok(Doc::Text(model));
755                    }
756                    "md" | "markdown" => quarb_text_koine::parse_markdown(&read()?),
757                    "html" => quarb_text_koine::parse_html(&read()?),
758                    "rst" => quarb_text_koine::parse_rst(&read()?),
759                    "org" => quarb_text_koine::parse_org(&read()?),
760                    "dj" | "djot" => quarb_text_koine::parse_djot(&read()?),
761                    "tei" | "docbook" | "jats" | "usx" | "osis" | "rnc" | "opencorpora"
762                    | "proiel" => quarb_text_koine::parse_xml_as(&read()?, &fmt),
763                    other => {
764                        return Err(anyhow::anyhow!(
765                            "unknown koine format {other:?} — known: md, html, rst, org, djot, tei, docbook, jats, usx, osis, atd"
766                        ));
767                    }
768                };
769                return imported.map(Doc::Text).with_context(|| {
770                    format!("importing {} through atrep as {fmt}", target.display())
771                });
772            }
773            let imported = match ext.as_deref() {
774                Some("atd" | "atk") => {
775                    let model = quarb_text_koine::parse_file(target).with_context(|| {
776                        format!("reading {} as an atrep document", target.display())
777                    })?;
778                    return Ok(Doc::Text(model));
779                }
780                Some("usfm" | "sfm") => quarb_text_koine::parse_xml_as(&read()?, "usfm"),
781                Some("bib") => quarb_text_koine::parse_bibtex(&read()?),
782                Some("md" | "markdown") => quarb_text_koine::parse_markdown(&read()?),
783                Some("html" | "htm") => quarb_text_koine::parse_html(&read()?),
784                Some("rst") => quarb_text_koine::parse_rst(&read()?),
785                Some("org") => quarb_text_koine::parse_org(&read()?),
786                Some("dj" | "djot") => quarb_text_koine::parse_djot(&read()?),
787                Some("xml") => {
788                    let text = read()?;
789                    match quarb_text_koine::detect_xml_kind(&text) {
790                        Some(kind) => quarb_text_koine::parse_xml_as(&text, kind),
791                        None => {
792                            return Err(anyhow::anyhow!(
793                                "{} declares no XML identity this route knows — force one with koine:{}?format=tei|docbook|jats|usx|osis|rnc|opencorpora|proiel",
794                                target.display(),
795                                target.display()
796                            ));
797                        }
798                    }
799                }
800                Some("pdf") => {
801                    return Err(anyhow::anyhow!(
802                        "atrep cannot import a PDF — the print reading is text:{}",
803                        target.display()
804                    ));
805                }
806                _ => {
807                    return Err(anyhow::anyhow!(
808                        "no atrep import for this format yet — the native reading is text:{}",
809                        target.display()
810                    ));
811                }
812            };
813            return imported
814                .map(Doc::Text)
815                .with_context(|| format!("importing {} through atrep", target.display()));
816        }
817        // The literary reading: the corpus reading with the inline
818        // simmeres kept — litogramma, TEI, USX and OSIS through
819        // atrep's importers; the unseen metadata as properties.
820        if let Some(rest) = s.strip_prefix("lit:")
821            && !rest.is_empty()
822        {
823            let (rest, conllu, annotate, desm, format, cast, places, modernize) = match rest
824                .split_once('?')
825            {
826                Some((p, q)) => {
827                    let (
828                        mut conllu,
829                        mut annotate,
830                        mut desm,
831                        mut format,
832                        mut cast,
833                        mut places,
834                        mut modernize,
835                    ) = (None, None, Vec::new(), None, None, None, None);
836                    for pair in q.split('&') {
837                        match pair.split_once('=') {
838                            Some(("conllu", v)) => conllu = Some(v.to_string()),
839                            Some(("annotate", v)) => annotate = Some(v.to_string()),
840                            Some(("desm", v)) => desm.push(v.to_string()),
841                            Some(("cast", v)) => cast = Some(v.to_string()),
842                            Some(("places", v)) => places = Some(v.to_string()),
843                            Some(("modernize", v)) => modernize = Some(v.to_string()),
844                            Some(("format", v)) => format = Some(v.to_string()),
845                            _ => bail!(
846                                "unknown lit option {pair:?} — supported: conllu=, annotate=, desm=, cast=, places=, modernize=, format="
847                            ),
848                        }
849                    }
850                    (p, conllu, annotate, desm, format, cast, places, modernize)
851                }
852                None => (rest, None, None, Vec::new(), None, None, None, None),
853            };
854            let target = Path::new(rest);
855            let ext = target
856                .extension()
857                .and_then(|e| e.to_str())
858                .map(|e| e.to_ascii_lowercase());
859            let read = || -> Result<String> {
860                let text = std::fs::read_to_string(target)
861                    .with_context(|| format!("reading {}", target.display()))?;
862                Ok(text
863                    .strip_prefix('\u{feff}')
864                    .map(str::to_owned)
865                    .unwrap_or(text))
866            };
867            let kind = match format {
868                Some(k) => k,
869                None => match ext.as_deref() {
870                    Some("atd" | "atk") => "atd".to_string(),
871                    Some("usx") => "usx".to_string(),
872                    _ => match quarb_text_koine::detect_xml_kind(&read()?) {
873                        Some(k) => k.to_string(),
874                        None => bail!(
875                            "lit: reads TEI, USX, OSIS, RNC, OpenCorpora, PROIEL and litogramma — {rest} declares none of them (koine:{rest} reads the other XML vocabularies, corpus:{rest} plain prose)"
876                        ),
877                    },
878                },
879            };
880            let mut model = match kind.as_str() {
881                "atd" | "atk" => quarb_text_koine::parse_lit_file(target).with_context(|| {
882                    format!("reading {} as an atrep document", target.display())
883                })?,
884                "tei" | "usx" | "osis" | "rnc" | "opencorpora" | "proiel" => {
885                    quarb_text_koine::parse_lit_xml_as(&read()?, &kind).with_context(|| {
886                        format!("reading {} as {}", target.display(), kind.to_uppercase())
887                    })?
888                }
889                other => bail!(
890                    "lit: reads TEI, USX, OSIS, RNC, OpenCorpora, PROIEL and litogramma — not {other} (koine:{rest} reads the other XML vocabularies)"
891                ),
892            };
893            model.set_document_path(rest);
894            return finish_corpus(
895                model, conllu, annotate, &desm, cast, places, modernize, opts, "lit",
896            );
897        }
898        // The corpus reading (ruling #62): the text-level reading
899        // with every prose block's tokens as children, the
900        // tokenizer decided once, at mount — where the resident
901        // session keeps it. An XML source takes the koine route
902        // (TEI and the other dialects); everything else the text:
903        // route; a PDF is refused, its reading being line geometry.
904        if let Some(rest) = s.strip_prefix("corpus:")
905            && !rest.is_empty()
906        {
907            // `?conllu=FILE` / `?annotate=CMD`, as in qua.
908            let (rest, conllu, annotate, desm, format, cast, places, modernize) = match rest
909                .split_once('?')
910            {
911                Some((p, q)) => {
912                    let (
913                        mut conllu,
914                        mut annotate,
915                        mut desm,
916                        mut format,
917                        mut cast,
918                        mut places,
919                        mut modernize,
920                    ) = (None, None, Vec::new(), None, None, None, None);
921                    for pair in q.split('&') {
922                        match pair.split_once('=') {
923                            Some(("conllu", v)) => conllu = Some(v.to_string()),
924                            Some(("annotate", v)) => annotate = Some(v.to_string()),
925                            Some(("desm", v)) => desm.push(v.to_string()),
926                            Some(("cast", v)) => cast = Some(v.to_string()),
927                            Some(("places", v)) => places = Some(v.to_string()),
928                            Some(("modernize", v)) => modernize = Some(v.to_string()),
929                            Some(("format", "conllu")) => format = Some("conllu"),
930                            Some(("format", v)) => bail!(
931                                "corpus: format={v} is not a treebank format — format= takes conllu"
932                            ),
933                            _ => bail!(
934                                "unknown corpus option {pair:?} — supported: conllu=, annotate=, desm=, cast=, places=, modernize=, format="
935                            ),
936                        }
937                    }
938                    (p, conllu, annotate, desm, format, cast, places, modernize)
939                }
940                None => (rest, None, None, Vec::new(), None, None, None, None),
941            };
942            let target = Path::new(rest);
943            let ext = target
944                .extension()
945                .and_then(|e| e.to_str())
946                .map(|e| e.to_ascii_lowercase());
947            if ext.as_deref() == Some("pdf") {
948                bail!(
949                    "corpus: needs a prose reading, and a PDF's is line geometry — text:{rest} reads its lines"
950                );
951            }
952            // A treebank is its own annotation (ruling #63): by
953            // extension, by `?format=conllu`, or sniffed on an
954            // extensionless file; a directory of treebank files is
955            // one corpus, a section per file.
956            let own_annotation = || {
957                if conllu.is_some() || annotate.is_some() {
958                    bail!(
959                        "corpus: {rest} is its own annotation — conllu= and annotate= apply to prose"
960                    );
961                }
962                Ok(())
963            };
964            if target.is_dir() {
965                own_annotation()?;
966                let files = quarb_text::read_conllu_dir(target).map_err(|e| {
967                    anyhow::anyhow!("corpus: over a directory reads its treebank — {e}")
968                })?;
969                let refs: Vec<(&str, &str)> = files
970                    .iter()
971                    .map(|(n, t)| (n.as_str(), t.as_str()))
972                    .collect();
973                let mut model = quarb_text::TextModel::parse_conllu_corpus_set(&refs)
974                    .map_err(|e| anyhow::anyhow!("reading {rest} as CoNLL-U: {e}"))?;
975                model.set_document_path(rest);
976                return treebank_options(model, &cast, &places, &modernize);
977            }
978            let treebank = matches!(ext.as_deref(), Some("conllu" | "conllup"))
979                || format == Some("conllu")
980                || (ext.is_none()
981                    && std::fs::read_to_string(target)
982                        .is_ok_and(|t| quarb_text::looks_like_conllu(&t)));
983            if treebank {
984                own_annotation()?;
985                let text = std::fs::read_to_string(target)
986                    .with_context(|| format!("reading {}", target.display()))?;
987                let text = text
988                    .strip_prefix('\u{feff}')
989                    .map(str::to_owned)
990                    .unwrap_or(text);
991                let Doc::Text(mut model) = Doc::parse(&text, "corpus-conllu")? else {
992                    bail!("corpus: needs a prose reading of {rest}")
993                };
994                model.set_document_path(rest);
995                return treebank_options(model, &cast, &places, &modernize);
996            }
997            let route = if matches!(ext.as_deref(), Some("xml" | "tei")) {
998                format!("koine:{rest}")
999            } else {
1000                format!("text:{rest}")
1001            };
1002            let Doc::Text(model) = Doc::open(Path::new(&route), opts)? else {
1003                bail!("corpus: needs a prose reading of {rest}")
1004            };
1005            return finish_corpus(
1006                model, conllu, annotate, &desm, cast, places, modernize, opts, "corpus",
1007            );
1008        }
1009        // `json2:FILE` — the property reading of a JSON-model
1010        // document (JSON, JSONL, YAML, TOML), matching qua.
1011        if let Some(rest) = s.strip_prefix("json2:")
1012            && !rest.is_empty()
1013        {
1014            use quarb_json::Reading;
1015            let target = Path::new(rest);
1016            let text = std::fs::read_to_string(target)
1017                .with_context(|| format!("reading {}", target.display()))?;
1018            let ext = target
1019                .extension()
1020                .and_then(|e| e.to_str())
1021                .map(|e| e.to_ascii_lowercase())
1022                .unwrap_or_default();
1023            let a = match ext.as_str() {
1024                "yaml" | "yml" => {
1025                    quarb_yaml::parse_as(&text, Reading::Property).context("parsing YAML")?
1026                }
1027                "toml" => quarb_toml::parse_as(&text, Reading::Property).context("parsing TOML")?,
1028                "jsonl" | "ndjson" => {
1029                    quarb_json::JsonAdapter::parse_lines_as(&text, Reading::Property)
1030                        .context("parsing JSONL")?
1031                }
1032                _ => quarb_json::JsonAdapter::parse_as(&text, Reading::Property)
1033                    .context("parsing JSON")?,
1034            };
1035            return Ok(Doc::Json(a));
1036        }
1037        // A `text:` prefix forces the text-level reading, matching
1038        // qua's dispatch: producer by extension, `<` sniffing
1039        // markup, plain paragraphs as the fallback.
1040        if let Some(rest) = s.strip_prefix("text:")
1041            && !rest.is_empty()
1042        {
1043            let target = Path::new(rest);
1044            // A directory of treebank files reads as one document,
1045            // a section per file (`corpus:` adds the tokens).
1046            if target.is_dir() {
1047                let files = quarb_text::read_conllu_dir(target).map_err(|e| {
1048                    anyhow::anyhow!("text: over a directory reads its treebank — {e}")
1049                })?;
1050                let refs: Vec<(&str, &str)> = files
1051                    .iter()
1052                    .map(|(n, t)| (n.as_str(), t.as_str()))
1053                    .collect();
1054                let mut model = quarb_text::TextModel::parse_conllu_text_set(&refs)
1055                    .map_err(|e| anyhow::anyhow!("reading {rest} as CoNLL-U: {e}"))?;
1056                model.set_document_path(rest);
1057                return Ok(Doc::Text(model));
1058            }
1059            // The binary producers dispatch before the text read:
1060            // a .docx or .epub is a zip, a .pdf its own format.
1061            let ext = target
1062                .extension()
1063                .and_then(|e| e.to_str())
1064                .map(|e| e.to_ascii_lowercase());
1065            // Native atrep files converge on the koine route.
1066            if let Some("usfm" | "sfm") = ext.as_deref() {
1067                let text = std::fs::read_to_string(target)
1068                    .with_context(|| format!("reading {}", target.display()))?;
1069                let text = text
1070                    .strip_prefix('\u{feff}')
1071                    .map(str::to_owned)
1072                    .unwrap_or(text);
1073                let model = quarb_text_koine::parse_xml_as(&text, "usfm")
1074                    .with_context(|| format!("reading {} as USFM", target.display()))?;
1075                return Ok(Doc::Text(model));
1076            }
1077            if let Some("atd" | "atk") = ext.as_deref() {
1078                let model = quarb_text_koine::parse_file(target).with_context(|| {
1079                    format!("reading {} as an atrep document", target.display())
1080                })?;
1081                return Ok(Doc::Text(model));
1082            }
1083            if ext.as_deref() == Some("pdf") {
1084                let bytes = std::fs::read(target)
1085                    .with_context(|| format!("reading {}", target.display()))?;
1086                let model = quarb_text_pdf::parse(&bytes)
1087                    .with_context(|| format!("reading {} as PDF", target.display()))?;
1088                let a = std::rc::Rc::new(model);
1089                let r = a.clone();
1090                return Ok(Doc::Boxed(
1091                    Dyn(Box::new(quarb_mount::Shared(a))),
1092                    Box::new(move |n| r.locator(n)),
1093                ));
1094            }
1095            if let Some(ext @ ("docx" | "epub")) = ext.as_deref() {
1096                let bytes = std::fs::read(target)
1097                    .with_context(|| format!("reading {}", target.display()))?;
1098                let model = if ext == "docx" {
1099                    let mut m = quarb_text_docx::parse(&bytes)
1100                        .with_context(|| format!("reading {} as Word", target.display()))?;
1101                    quarb_text_koine::attach_bib_aliases(&mut m);
1102                    m
1103                } else {
1104                    quarb_text_epub::parse(&bytes)
1105                        .with_context(|| format!("reading {} as EPUB", target.display()))?
1106                };
1107                return Ok(Doc::Text(model));
1108            }
1109            let text = std::fs::read_to_string(target)
1110                .with_context(|| format!("reading {}", target.display()))?;
1111            let text = text
1112                .strip_prefix('\u{feff}')
1113                .map(str::to_owned)
1114                .unwrap_or(text);
1115            let format = match target
1116                .extension()
1117                .and_then(|e| e.to_str())
1118                .map(|e| e.to_ascii_lowercase())
1119                .as_deref()
1120            {
1121                Some("html" | "htm") => "text-html",
1122                Some("md" | "markdown") => "text-markdown",
1123                Some("tex" | "latex") => "tex",
1124                Some("txt") => "text",
1125                Some("conllu" | "conllup") => "conllu",
1126                #[cfg(feature = "koine")]
1127                Some("bib") => "text-bibtex",
1128                _ if text.trim_start().starts_with('<') => "text-html",
1129                None if quarb_text::looks_like_conllu(&text) => "conllu",
1130                _ => "text",
1131            };
1132            return Doc::parse(&text, format);
1133        }
1134        // A `code:` prefix forces the code-level reading,
1135        // matching qua's dispatch; a directory mounts the
1136        // composed, grafted view with source leaves grafted
1137        // at the code level.
1138        if let Some(rest) = s.strip_prefix("code:")
1139            && !rest.is_empty()
1140        {
1141            if opts.no_graft {
1142                bail!(
1143                    "no_graft refuses the code: prefix: the prefix's whole \
1144                     meaning is the grafted code-level view"
1145                );
1146            }
1147            let target = Path::new(rest);
1148            if target.is_dir() {
1149                let fsopts = quarb_fs::FsOptions {
1150                    hidden: opts.hidden,
1151                    respect_ignore: opts.respect_ignore,
1152                };
1153                let fs = quarb_fs::FsAdapter::with_options(target, fsopts)
1154                    .with_context(|| format!("opening directory {}", target.display()))?;
1155                return Ok(Doc::FsDeep(
1156                    quarb_compose::ComposeAdapter::with_source_paths(fs, |fs, n| Some(fs.path(n)))
1157                        .with_source_graft(quarb_compose::SourceGraft::Code),
1158                ));
1159            }
1160            let a = quarb_code::CodeModel::open(target)
1161                .with_context(|| format!("parsing {} at the code level", target.display()))?;
1162            return Ok(Doc::Code(a));
1163        }
1164
1165        let ext = path
1166            .extension()
1167            .and_then(|e| e.to_str())
1168            .map(|e| e.to_ascii_lowercase());
1169
1170        if let Some(e) = &ext
1171            && quarb_tree_sitter::supported(e)
1172        {
1173            let a =
1174                quarb_tree_sitter::TreeSitterAdapter::open(path).context("parsing source file")?;
1175            return Ok(Doc::Syntax(a));
1176        }
1177        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
1178            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
1179            return Ok(Doc::Xlsx(a));
1180        }
1181        if is_sqlite(path) {
1182            // Refuse, never fall through: a .db is binary, and
1183            // letting it reach the text sniffers would trade a
1184            // clear absence for a confusing parse error.
1185            #[cfg(not(feature = "sqlite"))]
1186            anyhow::bail!(
1187                "{}: this quarb-session was built without the `sqlite` feature",
1188                path.display()
1189            );
1190            #[cfg(feature = "sqlite")]
1191            {
1192                let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
1193                    .context("opening SQLite database")?;
1194                return Ok(Doc::Sqlite(a));
1195            }
1196        }
1197        if is_archive(path) {
1198            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
1199            return Ok(if opts.no_graft {
1200                Doc::ArchiveRaw(a)
1201            } else {
1202                Doc::Archive(quarb_compose::ComposeAdapter::new(a))
1203            });
1204        }
1205
1206        // Text documents.
1207        let text =
1208            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1209        let text = text
1210            .strip_prefix('\u{feff}')
1211            .map(str::to_owned)
1212            .unwrap_or(text);
1213        match ext.as_deref() {
1214            Some("csv") => Doc::parse(&text, "csv"),
1215            Some("tsv") => Doc::parse(&text, "tsv"),
1216            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
1217            Some("toml") => Doc::parse(&text, "toml"),
1218            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
1219            Some("txt") => Doc::parse(&text, "text"),
1220            Some("conllu" | "conllup") => Doc::parse(&text, "conllu"),
1221            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
1222            _ => {
1223                if is_xml(path, &text) {
1224                    Doc::parse(&text, "xml")
1225                } else if is_html(path, &text) {
1226                    Doc::parse(&text, "html")
1227                } else {
1228                    Doc::parse(&text, "json")
1229                }
1230            }
1231        }
1232    }
1233
1234    /// Open several sources as named children of one root (file stem =
1235    /// mount name), so a single query — including a `<=>` join — spans
1236    /// them all.
1237    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
1238        let specs: Vec<crate::MountSpec> = paths
1239            .iter()
1240            .map(|p| crate::MountSpec {
1241                name: None,
1242                path: p.clone(),
1243            })
1244            .collect();
1245        Doc::mount_specs(&specs, opts)
1246    }
1247
1248    /// [`Doc::mount`] with optional explicit mount names
1249    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
1250    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
1251        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
1252        for (i, spec) in specs.iter().enumerate() {
1253            let name = spec.name.clone().unwrap_or_else(|| {
1254                spec.path
1255                    .file_stem()
1256                    .map(|s| s.to_string_lossy().into_owned())
1257                    .unwrap_or_else(|| format!("doc{i}"))
1258            });
1259            if mounts.iter().any(|m| m.name == name) {
1260                bail!(
1261                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
1262                     same name; give each a distinct basename (or a NAME=TARGET alias)",
1263                    spec.path.display()
1264                );
1265            }
1266            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
1267            mounts.push(quarb_mount::Mount {
1268                name,
1269                target: Some(spec.path.display().to_string()),
1270                adapter,
1271            });
1272        }
1273        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
1274    }
1275}
1276
1277/// A name-path locator built from the adapter trait alone
1278/// (`parent`/`name`) — used for a mount, whose per-source render
1279/// functions we do not keep.
1280fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
1281    let mut parts = Vec::new();
1282    let mut cur = Some(node);
1283    while let Some(n) = cur {
1284        if let Some(nm) = a.name(n) {
1285            parts.push(nm);
1286        }
1287        cur = a.parent(n);
1288    }
1289    parts.reverse();
1290    format!("/{}", parts.join("/"))
1291}
1292
1293/// Whether a file is a SQLite database — by extension, or the 16-byte
1294/// header magic.
1295#[cfg(feature = "native")]
1296fn is_sqlite(path: &Path) -> bool {
1297    if path
1298        .extension()
1299        .and_then(|e| e.to_str())
1300        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
1301    {
1302        return true;
1303    }
1304    use std::io::Read as _;
1305    let mut buf = [0u8; 16];
1306    std::fs::File::open(path)
1307        .and_then(|mut f| f.read_exact(&mut buf))
1308        .is_ok()
1309        && &buf == b"SQLite format 3\0"
1310}
1311
1312/// Whether a file is an archive — by extension, or zip/gzip magic.
1313#[cfg(feature = "native")]
1314fn is_archive(path: &Path) -> bool {
1315    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
1316        matches!(
1317            e.to_ascii_lowercase().as_str(),
1318            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
1319        )
1320    }) {
1321        return true;
1322    }
1323    use std::io::Read as _;
1324    let mut buf = [0u8; 2];
1325    std::fs::File::open(path)
1326        .and_then(|mut f| f.read_exact(&mut buf))
1327        .is_ok()
1328        && (&buf == b"PK" || buf == [0x1f, 0x8b])
1329}
1330
1331/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
1332/// `<?xml` prolog.
1333#[cfg(feature = "native")]
1334fn is_xml(path: &Path, text: &str) -> bool {
1335    path.extension()
1336        .and_then(|e| e.to_str())
1337        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
1338        || text.trim_start().starts_with("<?xml")
1339}
1340
1341/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
1342/// starts with `<`.
1343#[cfg(feature = "native")]
1344fn is_html(path: &Path, text: &str) -> bool {
1345    path.extension()
1346        .and_then(|e| e.to_str())
1347        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
1348        || text.trim_start().starts_with('<')
1349}
1350
1351/// The corpus reading's second half, shared with `lit:`: the
1352/// sidecar or command annotation, else the tokenizer with the
1353/// session's and the mount's sentence bonds.
1354/// A treebank is its own tokens; the cast table and the spelling
1355/// table of the mount still apply.
1356fn treebank_options(
1357    mut model: quarb_text::TextModel,
1358    cast: &Option<String>,
1359    places: &Option<String>,
1360    modernize: &Option<String>,
1361) -> Result<Doc> {
1362    if let Some(t) = modernize {
1363        model
1364            .set_orthography(t)
1365            .map_err(|e| anyhow::anyhow!("corpus: {e}"))?;
1366    }
1367    if let Some(f) = cast {
1368        let rows = quarb_text::CastRow::read_csv(f)
1369            .map_err(|e| anyhow::anyhow!("corpus: reading the cast table {f}: {e}"))?;
1370        model.apply_cast(&rows);
1371    }
1372    if let Some(f) = places {
1373        let rows = quarb_text::CastRow::read_csv(f)
1374            .map_err(|e| anyhow::anyhow!("corpus: reading the places table {f}: {e}"))?;
1375        model.apply_places(&rows);
1376    }
1377    Ok(Doc::Text(model))
1378}
1379
1380fn finish_corpus(
1381    mut model: quarb_text::TextModel,
1382    conllu: Option<String>,
1383    annotate: Option<String>,
1384    desm: &[String],
1385    cast: Option<String>,
1386    places: Option<String>,
1387    modernize: Option<String>,
1388    opts: &Options,
1389    level: &str,
1390) -> Result<Doc> {
1391    if let Some(table) = &modernize {
1392        model
1393            .set_orthography(table)
1394            .map_err(|e| anyhow::anyhow!("{level}: {e}"))?;
1395    }
1396    let conllu_text = match (conllu, annotate) {
1397        (Some(_), Some(_)) => bail!("{level}: takes conllu= or annotate=, not both"),
1398        (Some(file), None) => Some(
1399            std::fs::read_to_string(&file).with_context(|| format!("reading {file} as CoNLL-U"))?,
1400        ),
1401        (None, Some(cmd)) => {
1402            if !opts.allow_shell {
1403                bail!("{level}: annotate= runs a command; the shell gate is closed");
1404            }
1405            use std::io::Write;
1406            let mut child = std::process::Command::new("sh")
1407                .arg("-c")
1408                .arg(&cmd)
1409                .stdin(std::process::Stdio::piped())
1410                .stdout(std::process::Stdio::piped())
1411                .spawn()
1412                .with_context(|| format!("running annotator {cmd:?}"))?;
1413            if let Some(mut stdin) = child.stdin.take() {
1414                stdin
1415                    .write_all(model.corpus_text().as_bytes())
1416                    .context("feeding the annotator")?;
1417            }
1418            let out = child
1419                .wait_with_output()
1420                .context("waiting for the annotator")?;
1421            if !out.status.success() {
1422                bail!("annotator {cmd:?} failed: {}", out.status);
1423            }
1424            Some(String::from_utf8(out.stdout).context("annotator output is not UTF-8")?)
1425        }
1426        (None, None) => None,
1427    };
1428    match conllu_text {
1429        Some(text) => model
1430            .annotate_conllu(&text)
1431            .map_err(|e| anyhow::anyhow!("{level}: {e}"))?,
1432        None => {
1433            let mut bonds = opts.bonds.as_ref().map(|b| (**b).clone());
1434            for f in desm {
1435                let more = syndesmos::Syndesmos::load(f)
1436                    .map_err(|e| anyhow::anyhow!("reading {f}: {e}"))?;
1437                bonds
1438                    .get_or_insert_with(syndesmos::Syndesmos::empty)
1439                    .extend(more);
1440            }
1441            model.tokenize_with(bonds.as_ref())
1442        }
1443    }
1444    if let Some(file) = cast {
1445        let rows = quarb_text::CastRow::read_csv(&file)
1446            .map_err(|e| anyhow::anyhow!("{level}: reading the cast table {file}: {e}"))?;
1447        model.apply_cast(&rows);
1448    }
1449    if let Some(file) = places {
1450        let rows = quarb_text::CastRow::read_csv(&file)
1451            .map_err(|e| anyhow::anyhow!("{level}: reading the places table {file}: {e}"))?;
1452        model.apply_places(&rows);
1453    }
1454    Ok(Doc::Text(model))
1455}