Skip to main content

quarb_session/
doc.rs

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