Skip to main content

quarb_session/
doc.rs

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