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