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