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    pub descend: bool,
30    /// Declared references, `(field, container)` pairs — the parsed
31    /// `--refs` document, consumed by the SQLite mounts.
32    pub refs: Rc<Vec<(String, String)>>,
33}
34
35/// A materialized source: one variant per adapter family. JSON-model
36/// formats (json/yaml/toml) render node results as pointers, the rest
37/// as locators.
38pub enum Doc {
39    Json(quarb_json::JsonAdapter),
40    Csv(quarb_csv::CsvAdapter),
41    Xml(quarb_xml::XmlAdapter),
42    Html(quarb_html::HtmlAdapter),
43    Text(quarb_text::TextModel),
44    #[cfg(feature = "sqlite")]
45    Sqlite(quarb_sqlite::SqliteAdapter),
46    #[cfg(feature = "native")]
47    Fs(quarb_fs::FsAdapter),
48    #[cfg(feature = "native")]
49    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
50    #[cfg(feature = "native")]
51    Git(quarb_git::GitAdapter),
52    #[cfg(feature = "native")]
53    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
54    #[cfg(feature = "native")]
55    Xlsx(quarb_xlsx::XlsxAdapter),
56    #[cfg(feature = "native")]
57    Code(quarb_code::CodeAdapter),
58    Mount(quarb_mount::MountAdapter),
59    /// Any adapter behind the object-safe trait, with its locator
60    /// renderer — the carrier for scheme targets opened through
61    /// qua's dispatch (`gcl:`, `kafka:`, `neo4j://`, …).
62    Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
63}
64
65/// A boxed adapter as an adapter — plain delegation (the
66/// quarb-py `Dyn` pattern).
67pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
68
69impl quarb::AstAdapter for Dyn {
70    fn root(&self) -> NodeId {
71        self.0.root()
72    }
73    fn children(&self, node: NodeId) -> Vec<NodeId> {
74        self.0.children(node)
75    }
76    fn name(&self, node: NodeId) -> Option<String> {
77        self.0.name(node)
78    }
79    fn parent(&self, node: NodeId) -> Option<NodeId> {
80        self.0.parent(node)
81    }
82    fn traits(&self, node: NodeId) -> Vec<String> {
83        self.0.traits(node)
84    }
85    fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
86        self.0.property(node, name)
87    }
88    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
89        self.0.children_named(node, name)
90    }
91    fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
92        self.0.default_value(node)
93    }
94    fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
95        self.0.metadata(node, key)
96    }
97    fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
98        self.0.aliased_metadata(node)
99    }
100    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
101        self.0.links(node)
102    }
103    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
104        self.0.backlinks(node)
105    }
106    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
107        self.0.resolve(node, property, hint)
108    }
109    fn link_property(
110        &self,
111        source: NodeId,
112        label: &str,
113        target: NodeId,
114        name: &str,
115    ) -> Option<quarb::Value> {
116        self.0.link_property(source, label, target, name)
117    }
118    fn quantifier_bound(&self) -> usize {
119        self.0.quantifier_bound()
120    }
121    fn invocation_instant(&self) -> Option<(i64, u32)> {
122        self.0.invocation_instant()
123    }
124    fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
125        self.0.unit_scale(expr)
126    }
127}
128
129impl Doc {
130    /// The kaiv door: the adapter is Rc-shared between the Doc and
131    /// its locator renderer (the `Shared` pattern qua's scheme
132    /// mounts use), riding the Boxed variant.
133    fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
134        let a = std::rc::Rc::new(a);
135        let r = a.clone();
136        Doc::Boxed(
137            Dyn(Box::new(quarb_mount::Shared(a))),
138            Box::new(move |n| r.locator(n)),
139        )
140    }
141
142    /// Parse a text document by format name — the wasm entry point,
143    /// and the text tail of the native `open`. Formats: json, yaml,
144    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson, kaiv/daiv.
145    pub fn parse(input: &str, format: &str) -> Result<Doc> {
146        match format {
147            // kaiv rides the Boxed door (no dedicated variant): the
148            // offline resolver — a browser mount has no filesystem
149            // or registry, so `.!units`/`.!types` imports beyond the
150            // embedded core fail with kaiv's own pointed error.
151            "kaiv" => {
152                let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
153                    .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
154                return Ok(Self::boxed_kaiv(a));
155            }
156            "daiv" => {
157                let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
158                    .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
159                return Ok(Self::boxed_kaiv(a));
160            }
161            "json" => quarb_json::JsonAdapter::parse(input)
162                .map(Doc::Json)
163                .context("parsing JSON"),
164            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
165                .map(Doc::Json)
166                .context("parsing JSONL"),
167            "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
168            "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
169            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
170                .map(Doc::Csv)
171                .context("parsing CSV"),
172            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
173                .map(Doc::Csv)
174                .context("parsing TSV"),
175            "xml" => quarb_xml::XmlAdapter::parse(input)
176                .map(Doc::Xml)
177                .context("parsing XML"),
178            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
179            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
180            // The text level: the shared section/paragraph
181            // vocabulary, produced per source format ("text" is
182            // plain text — blank-line paragraphs).
183            "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
184            "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
185            "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
186            other => bail!("unknown format: {other}"),
187        }
188    }
189
190    /// Run one query against this source with the session's invocation
191    /// instant and shell permission. The query text carries any macro
192    /// definitions inline (the session prepends its table), which
193    /// `quarb::run` expands.
194    /// The concrete adapter behind this `Doc`, as `&dyn` — the base a
195    /// `--model` enrichment layer wraps (one match, so the model
196    /// paths avoid duplicating the variant arms). Wasm-safe; the
197    /// native-only variants are compiled in only under `native`.
198    pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
199        match self {
200            Doc::Json(a) => a,
201            Doc::Csv(a) => a,
202            Doc::Xml(a) => a,
203            Doc::Html(a) => a,
204            Doc::Text(a) => a,
205            #[cfg(feature = "sqlite")]
206            Doc::Sqlite(a) => a,
207            #[cfg(feature = "native")]
208            Doc::Fs(a) => a,
209            #[cfg(feature = "native")]
210            Doc::FsDeep(a) => a,
211            #[cfg(feature = "native")]
212            Doc::Git(a) => a,
213            #[cfg(feature = "native")]
214            Doc::Archive(a) => a,
215            #[cfg(feature = "native")]
216            Doc::Xlsx(a) => a,
217            #[cfg(feature = "native")]
218            Doc::Code(a) => a,
219            Doc::Mount(a) => a,
220            Doc::Boxed(a, _) => &*a.0,
221        }
222    }
223
224    /// Run `query` and render its results as exportable markup:
225    /// `md`/`markdown`, `html`, or `txt`/`text`. Node results render
226    /// structurally through the text vocabulary — sections back to
227    /// headings, lists to lists — and kinds outside it degrade to
228    /// prose paragraphs; value results render as lines.
229    pub fn export(
230        &self,
231        query: &str,
232        now: (i64, u32),
233        allow_shell: bool,
234        kind: &str,
235    ) -> Result<String> {
236        let render = quarb_text::Render::from_name(kind)
237            .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
238        // An empty query exports the whole document.
239        if query.trim().is_empty() {
240            let base = self.base_dyn();
241            return Ok(quarb_text::render_nodes(base, &[base.root()], render));
242        }
243        match self
244            .run(query, now, allow_shell)
245            .map_err(|e| anyhow::anyhow!("{e}"))?
246        {
247            QueryResult::Nodes(nodes) => {
248                Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
249            }
250            QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
251        }
252    }
253
254    /// Run against a `--model`-enriched view of this source: the
255    /// derived containers, references, and edges the model declares,
256    /// over this `Doc`'s base. `now` binds `now()` for the base and
257    /// its constructor queries alike.
258    pub fn run_modeled(
259        &self,
260        query: &str,
261        now: (i64, u32),
262        allow_shell: bool,
263        model: &quarb_model::Model,
264    ) -> quarb::Result<QueryResult> {
265        let (secs, nanos) = now;
266        let base = quarb_model::Borrowed(self.base_dyn());
267        let nowed = WithNow {
268            inner: &base,
269            secs,
270            nanos,
271        };
272        let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
273        if allow_shell {
274            quarb::run(query, &AllowShell { inner: &enriched })
275        } else {
276            quarb::run(query, &enriched)
277        }
278    }
279
280    /// Render a node from a model-enriched run: `/container/value`
281    /// for derived nodes, the base's own renderer otherwise.
282    pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
283        let enriched =
284            quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
285        enriched.locator(node, |bn| self.render(bn))
286    }
287
288    pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
289        let (secs, nanos) = now;
290        macro_rules! go {
291            ($a:expr) => {{
292                let nowed = WithNow {
293                    inner: $a,
294                    secs,
295                    nanos,
296                };
297                if allow_shell {
298                    quarb::run(query, &AllowShell { inner: &nowed })
299                } else {
300                    quarb::run(query, &nowed)
301                }
302            }};
303        }
304        match self {
305            Doc::Json(a) => go!(a),
306            Doc::Csv(a) => go!(a),
307            Doc::Xml(a) => go!(a),
308            Doc::Html(a) => go!(a),
309            Doc::Text(a) => go!(a),
310            #[cfg(feature = "sqlite")]
311            Doc::Sqlite(a) => go!(a),
312            #[cfg(feature = "native")]
313            Doc::Fs(a) => go!(a),
314            #[cfg(feature = "native")]
315            Doc::FsDeep(a) => go!(a),
316            #[cfg(feature = "native")]
317            Doc::Git(a) => go!(a),
318            #[cfg(feature = "native")]
319            Doc::Archive(a) => go!(a),
320            #[cfg(feature = "native")]
321            Doc::Xlsx(a) => go!(a),
322            #[cfg(feature = "native")]
323            Doc::Code(a) => go!(a),
324            Doc::Mount(a) => go!(a),
325            Doc::Boxed(a, _) => go!(a),
326        }
327    }
328
329    /// Render a node result as its source-appropriate locator.
330    pub fn render(&self, node: NodeId) -> String {
331        match self {
332            Doc::Json(a) => a.pointer(node),
333            Doc::Csv(a) => a.locator(node),
334            Doc::Xml(a) => a.locator(node),
335            Doc::Html(a) => a.locator(node),
336            Doc::Text(a) => a.locator(node),
337            #[cfg(feature = "sqlite")]
338            Doc::Sqlite(a) => a.locator(node),
339            #[cfg(feature = "native")]
340            Doc::Fs(a) => a.path(node).display().to_string(),
341            #[cfg(feature = "native")]
342            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
343            #[cfg(feature = "native")]
344            Doc::Git(a) => a.locator(node),
345            #[cfg(feature = "native")]
346            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
347            #[cfg(feature = "native")]
348            Doc::Xlsx(a) => a.locator(node),
349            #[cfg(feature = "native")]
350            Doc::Code(a) => a.locator(node),
351            Doc::Mount(a) => generic_locator(a, node),
352            Doc::Boxed(_, render) => render(node),
353        }
354    }
355
356    /// Open a SQLite database from its file bytes — a `.db` that
357    /// never touched a filesystem (the browser's uploaded files).
358    #[cfg(feature = "sqlite")]
359    pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
360        Ok(Doc::Sqlite(
361            quarb_sqlite::SqliteAdapter::from_bytes(bytes)
362                .map_err(|e| anyhow::anyhow!("{e}"))
363                .context("opening SQLite bytes")?,
364        ))
365    }
366
367    /// The refusal twin: the API is present either way, so a
368    /// consumer compiles against both builds and the absence
369    /// reports itself instead of failing to link.
370    #[cfg(not(feature = "sqlite"))]
371    pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
372        anyhow::bail!(
373            "this quarb-session was built without the `sqlite` feature"
374        )
375    }
376
377    /// Mount already-built documents as named children of one root —
378    /// the general wasm-safe mount, for callers that assembled their
379    /// `Doc`s from text or bytes rather than paths.
380    pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
381        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
382        for (name, doc) in parts {
383            if mounts.iter().any(|m| m.name == name) {
384                bail!("two sources mount as '{name}'; give each a distinct name");
385            }
386            mounts.push(quarb_mount::Mount {
387                name,
388                // Assembled from text/bytes: no real-world address to
389                // record — the mount name stands in for :::source.
390                target: None,
391                adapter: doc.into_boxed()?,
392            });
393        }
394        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
395    }
396
397    /// Mount several already-parsed text documents as named children
398    /// of one root — [`Doc::mount_docs`] over [`Doc::parse`], for
399    /// callers that hold text (the browser playground's paste
400    /// boxes). `parts` is `(name, format, text)`.
401    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
402        let mut docs: Vec<(String, Doc)> = Vec::new();
403        for (name, format, text) in parts {
404            let doc = Doc::parse(text, format)
405                .with_context(|| format!("parsing '{name}' as {format}"))?;
406            docs.push((name.clone(), doc));
407        }
408        Doc::mount_docs(docs)
409    }
410
411    /// Box this source as a shared adapter — a mount child.
412    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
413        use quarb_mount::Shared;
414        Ok(match self {
415            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
416            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
417            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
418            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
419            Doc::Text(a) => Box::new(Shared(Rc::new(a))),
420            #[cfg(feature = "sqlite")]
421            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
422            #[cfg(feature = "native")]
423            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
424            #[cfg(feature = "native")]
425            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
426            #[cfg(feature = "native")]
427            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
428            #[cfg(feature = "native")]
429            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
430            #[cfg(feature = "native")]
431            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
432            #[cfg(feature = "native")]
433            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
434            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
435            Doc::Boxed(a, _) => a.0,
436        })
437    }
438}
439
440// ---------------------------------------------------------------------
441// Native-only: filesystem/db/git dispatch and multi-source mounts.
442// ---------------------------------------------------------------------
443
444#[cfg(feature = "native")]
445impl Doc {
446    /// Open one path as a local source. Directories are filesystem
447    /// trees (`--descend` grafts parseable leaves); `git:PATH` opens a
448    /// repository; binary kinds (SQLite, spreadsheets, archives) and
449    /// source files dispatch by extension/magic; everything else is a
450    /// text document parsed by extension or content sniff.
451    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
452        if path.is_dir() {
453            let fsopts = quarb_fs::FsOptions {
454                hidden: opts.hidden,
455                respect_ignore: opts.respect_ignore,
456            };
457            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
458                .with_context(|| format!("opening directory {}", path.display()))?;
459            return Ok(if opts.descend {
460                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
461                    fs,
462                    |fs, n| Some(fs.path(n)),
463                ))
464            } else {
465                Doc::Fs(fs)
466            });
467        }
468
469        let s = path.to_string_lossy();
470        if let Some(repo) = s.strip_prefix("git:") {
471            let a =
472                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
473            return Ok(Doc::Git(a));
474        }
475        // A `text:` prefix forces the text-level reading, matching
476        // qua's dispatch: producer by extension, `<` sniffing
477        // markup, plain paragraphs as the fallback.
478        if let Some(rest) = s.strip_prefix("text:")
479            && !rest.is_empty()
480        {
481            let target = Path::new(rest);
482            let text = std::fs::read_to_string(target)
483                .with_context(|| format!("reading {}", target.display()))?;
484            let text = text
485                .strip_prefix('\u{feff}')
486                .map(str::to_owned)
487                .unwrap_or(text);
488            let format = match target
489                .extension()
490                .and_then(|e| e.to_str())
491                .map(|e| e.to_ascii_lowercase())
492                .as_deref()
493            {
494                Some("html" | "htm") => "text-html",
495                Some("md" | "markdown") => "text-markdown",
496                Some("txt") => "text",
497                _ if text.trim_start().starts_with('<') => "text-html",
498                _ => "text",
499            };
500            return Doc::parse(&text, format);
501        }
502
503        let ext = path
504            .extension()
505            .and_then(|e| e.to_str())
506            .map(|e| e.to_ascii_lowercase());
507
508        if let Some(e) = &ext
509            && quarb_code::supported(e)
510        {
511            let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
512            return Ok(Doc::Code(a));
513        }
514        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
515            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
516            return Ok(Doc::Xlsx(a));
517        }
518        if is_sqlite(path) {
519            // Refuse, never fall through: a .db is binary, and
520            // letting it reach the text sniffers would trade a
521            // clear absence for a confusing parse error.
522            #[cfg(not(feature = "sqlite"))]
523            anyhow::bail!(
524                "{}: this quarb-session was built without the `sqlite` feature",
525                path.display()
526            );
527            #[cfg(feature = "sqlite")]
528            {
529                let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
530                    .context("opening SQLite database")?;
531                return Ok(Doc::Sqlite(a));
532            }
533        }
534        if is_archive(path) {
535            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
536            return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
537        }
538
539        // Text documents.
540        let text = std::fs::read_to_string(path)
541            .with_context(|| format!("reading {}", path.display()))?;
542        let text = text
543            .strip_prefix('\u{feff}')
544            .map(str::to_owned)
545            .unwrap_or(text);
546        match ext.as_deref() {
547            Some("csv") => Doc::parse(&text, "csv"),
548            Some("tsv") => Doc::parse(&text, "tsv"),
549            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
550            Some("toml") => Doc::parse(&text, "toml"),
551            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
552            Some("txt") => Doc::parse(&text, "text"),
553            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
554            _ => {
555                if is_xml(path, &text) {
556                    Doc::parse(&text, "xml")
557                } else if is_html(path, &text) {
558                    Doc::parse(&text, "html")
559                } else {
560                    Doc::parse(&text, "json")
561                }
562            }
563        }
564    }
565
566    /// Open several sources as named children of one root (file stem =
567    /// mount name), so a single query — including a `<=>` join — spans
568    /// them all.
569    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
570        let specs: Vec<crate::MountSpec> = paths
571            .iter()
572            .map(|p| crate::MountSpec {
573                name: None,
574                path: p.clone(),
575            })
576            .collect();
577        Doc::mount_specs(&specs, opts)
578    }
579
580    /// [`Doc::mount`] with optional explicit mount names
581    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
582    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
583        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
584        for (i, spec) in specs.iter().enumerate() {
585            let name = spec.name.clone().unwrap_or_else(|| {
586                spec.path
587                    .file_stem()
588                    .map(|s| s.to_string_lossy().into_owned())
589                    .unwrap_or_else(|| format!("doc{i}"))
590            });
591            if mounts.iter().any(|m| m.name == name) {
592                bail!(
593                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
594                     same name; give each a distinct basename (or a NAME=TARGET alias)",
595                    spec.path.display()
596                );
597            }
598            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
599            mounts.push(quarb_mount::Mount {
600                name,
601                target: Some(spec.path.display().to_string()),
602                adapter,
603            });
604        }
605        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
606    }
607
608}
609
610/// A name-path locator built from the adapter trait alone
611/// (`parent`/`name`) — used for a mount, whose per-source render
612/// functions we do not keep.
613fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
614    let mut parts = Vec::new();
615    let mut cur = Some(node);
616    while let Some(n) = cur {
617        if let Some(nm) = a.name(n) {
618            parts.push(nm);
619        }
620        cur = a.parent(n);
621    }
622    parts.reverse();
623    format!("/{}", parts.join("/"))
624}
625
626/// Whether a file is a SQLite database — by extension, or the 16-byte
627/// header magic.
628#[cfg(feature = "native")]
629fn is_sqlite(path: &Path) -> bool {
630    if path
631        .extension()
632        .and_then(|e| e.to_str())
633        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
634    {
635        return true;
636    }
637    use std::io::Read as _;
638    let mut buf = [0u8; 16];
639    std::fs::File::open(path)
640        .and_then(|mut f| f.read_exact(&mut buf))
641        .is_ok()
642        && &buf == b"SQLite format 3\0"
643}
644
645/// Whether a file is an archive — by extension, or zip/gzip magic.
646#[cfg(feature = "native")]
647fn is_archive(path: &Path) -> bool {
648    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
649        matches!(
650            e.to_ascii_lowercase().as_str(),
651            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
652        )
653    }) {
654        return true;
655    }
656    use std::io::Read as _;
657    let mut buf = [0u8; 2];
658    std::fs::File::open(path)
659        .and_then(|mut f| f.read_exact(&mut buf))
660        .is_ok()
661        && (&buf == b"PK" || buf == [0x1f, 0x8b])
662}
663
664/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
665/// `<?xml` prolog.
666#[cfg(feature = "native")]
667fn is_xml(path: &Path, text: &str) -> bool {
668    path.extension()
669        .and_then(|e| e.to_str())
670        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
671        || text.trim_start().starts_with("<?xml")
672}
673
674/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
675/// starts with `<`.
676#[cfg(feature = "native")]
677fn is_html(path: &Path, text: &str) -> bool {
678    path.extension()
679        .and_then(|e| e.to_str())
680        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
681        || text.trim_start().starts_with('<')
682}