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