Skip to main content

quarb_session/
doc.rs

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