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, Copy, Default)]
26pub struct Options {
27    pub hidden: bool,
28    pub respect_ignore: bool,
29    pub descend: bool,
30}
31
32/// A materialized source: one variant per adapter family. JSON-model
33/// formats (json/yaml/toml) render node results as pointers, the rest
34/// as locators.
35pub enum Doc {
36    Json(quarb_json::JsonAdapter),
37    Csv(quarb_csv::CsvAdapter),
38    Xml(quarb_xml::XmlAdapter),
39    Html(quarb_html::HtmlAdapter),
40    Sqlite(quarb_sqlite::SqliteAdapter),
41    #[cfg(feature = "native")]
42    Fs(quarb_fs::FsAdapter),
43    #[cfg(feature = "native")]
44    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
45    #[cfg(feature = "native")]
46    Git(quarb_git::GitAdapter),
47    #[cfg(feature = "native")]
48    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
49    #[cfg(feature = "native")]
50    Xlsx(quarb_xlsx::XlsxAdapter),
51    #[cfg(feature = "native")]
52    Code(quarb_code::CodeAdapter),
53    Mount(quarb_mount::MountAdapter),
54    /// Any adapter behind the object-safe trait, with its locator
55    /// renderer — the carrier for scheme targets opened through
56    /// qua's dispatch (`gcl:`, `kafka:`, `neo4j://`, …).
57    #[cfg(feature = "native")]
58    Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
59}
60
61/// A boxed adapter as an adapter — plain delegation (the
62/// quarb-py `Dyn` pattern).
63#[cfg(feature = "native")]
64pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
65
66#[cfg(feature = "native")]
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    /// Parse a text document by format name — the wasm entry point,
126    /// and the text tail of the native `open`. Formats: json, yaml,
127    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson.
128    pub fn parse(input: &str, format: &str) -> Result<Doc> {
129        match format {
130            "json" => quarb_json::JsonAdapter::parse(input)
131                .map(Doc::Json)
132                .context("parsing JSON"),
133            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
134                .map(Doc::Json)
135                .context("parsing JSONL"),
136            "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
137            "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
138            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
139                .map(Doc::Csv)
140                .context("parsing CSV"),
141            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
142                .map(Doc::Csv)
143                .context("parsing TSV"),
144            "xml" => quarb_xml::XmlAdapter::parse(input)
145                .map(Doc::Xml)
146                .context("parsing XML"),
147            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
148            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
149            other => bail!("unknown format: {other}"),
150        }
151    }
152
153    /// Run one query against this source with the session's invocation
154    /// instant and shell permission. The query text carries any macro
155    /// definitions inline (the session prepends its table), which
156    /// `quarb::run` expands.
157    pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
158        let (secs, nanos) = now;
159        macro_rules! go {
160            ($a:expr) => {{
161                let nowed = WithNow {
162                    inner: $a,
163                    secs,
164                    nanos,
165                };
166                if allow_shell {
167                    quarb::run(query, &AllowShell { inner: &nowed })
168                } else {
169                    quarb::run(query, &nowed)
170                }
171            }};
172        }
173        match self {
174            Doc::Json(a) => go!(a),
175            Doc::Csv(a) => go!(a),
176            Doc::Xml(a) => go!(a),
177            Doc::Html(a) => go!(a),
178            Doc::Sqlite(a) => go!(a),
179            #[cfg(feature = "native")]
180            Doc::Fs(a) => go!(a),
181            #[cfg(feature = "native")]
182            Doc::FsDeep(a) => go!(a),
183            #[cfg(feature = "native")]
184            Doc::Git(a) => go!(a),
185            #[cfg(feature = "native")]
186            Doc::Archive(a) => go!(a),
187            #[cfg(feature = "native")]
188            Doc::Xlsx(a) => go!(a),
189            #[cfg(feature = "native")]
190            Doc::Code(a) => go!(a),
191            Doc::Mount(a) => go!(a),
192            #[cfg(feature = "native")]
193            Doc::Boxed(a, _) => go!(a),
194        }
195    }
196
197    /// Render a node result as its source-appropriate locator.
198    pub fn render(&self, node: NodeId) -> String {
199        match self {
200            Doc::Json(a) => a.pointer(node),
201            Doc::Csv(a) => a.locator(node),
202            Doc::Xml(a) => a.locator(node),
203            Doc::Html(a) => a.locator(node),
204            Doc::Sqlite(a) => a.locator(node),
205            #[cfg(feature = "native")]
206            Doc::Fs(a) => a.path(node).display().to_string(),
207            #[cfg(feature = "native")]
208            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
209            #[cfg(feature = "native")]
210            Doc::Git(a) => a.locator(node),
211            #[cfg(feature = "native")]
212            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
213            #[cfg(feature = "native")]
214            Doc::Xlsx(a) => a.locator(node),
215            #[cfg(feature = "native")]
216            Doc::Code(a) => a.locator(node),
217            Doc::Mount(a) => generic_locator(a, node),
218            #[cfg(feature = "native")]
219            Doc::Boxed(_, render) => render(node),
220        }
221    }
222
223    /// Open a SQLite database from its file bytes — a `.db` that
224    /// never touched a filesystem (the browser's uploaded files).
225    pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
226        Ok(Doc::Sqlite(
227            quarb_sqlite::SqliteAdapter::from_bytes(bytes)
228                .map_err(|e| anyhow::anyhow!("{e}"))
229                .context("opening SQLite bytes")?,
230        ))
231    }
232
233    /// Mount already-built documents as named children of one root —
234    /// the general wasm-safe mount, for callers that assembled their
235    /// `Doc`s from text or bytes rather than paths.
236    pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
237        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
238        for (name, doc) in parts {
239            if mounts.iter().any(|m| m.name == name) {
240                bail!("two sources mount as '{name}'; give each a distinct name");
241            }
242            mounts.push(quarb_mount::Mount {
243                name,
244                adapter: doc.into_boxed()?,
245            });
246        }
247        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
248    }
249
250    /// Mount several already-parsed text documents as named children
251    /// of one root — [`Doc::mount_docs`] over [`Doc::parse`], for
252    /// callers that hold text (the browser playground's paste
253    /// boxes). `parts` is `(name, format, text)`.
254    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
255        let mut docs: Vec<(String, Doc)> = Vec::new();
256        for (name, format, text) in parts {
257            let doc = Doc::parse(text, format)
258                .with_context(|| format!("parsing '{name}' as {format}"))?;
259            docs.push((name.clone(), doc));
260        }
261        Doc::mount_docs(docs)
262    }
263
264    /// Box this source as a shared adapter — a mount child.
265    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
266        use quarb_mount::Shared;
267        Ok(match self {
268            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
269            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
270            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
271            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
272            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
273            #[cfg(feature = "native")]
274            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
275            #[cfg(feature = "native")]
276            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
277            #[cfg(feature = "native")]
278            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
279            #[cfg(feature = "native")]
280            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
281            #[cfg(feature = "native")]
282            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
283            #[cfg(feature = "native")]
284            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
285            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
286            #[cfg(feature = "native")]
287            Doc::Boxed(a, _) => a.0,
288        })
289    }
290}
291
292// ---------------------------------------------------------------------
293// Native-only: filesystem/db/git dispatch and multi-source mounts.
294// ---------------------------------------------------------------------
295
296#[cfg(feature = "native")]
297impl Doc {
298    /// Open one path as a local source. Directories are filesystem
299    /// trees (`--descend` grafts parseable leaves); `git:PATH` opens a
300    /// repository; binary kinds (SQLite, spreadsheets, archives) and
301    /// source files dispatch by extension/magic; everything else is a
302    /// text document parsed by extension or content sniff.
303    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
304        if path.is_dir() {
305            let fsopts = quarb_fs::FsOptions {
306                hidden: opts.hidden,
307                respect_ignore: opts.respect_ignore,
308            };
309            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
310                .with_context(|| format!("opening directory {}", path.display()))?;
311            return Ok(if opts.descend {
312                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
313                    fs,
314                    |fs, n| Some(fs.path(n)),
315                ))
316            } else {
317                Doc::Fs(fs)
318            });
319        }
320
321        let s = path.to_string_lossy();
322        if let Some(repo) = s.strip_prefix("git:") {
323            let a =
324                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
325            return Ok(Doc::Git(a));
326        }
327
328        let ext = path
329            .extension()
330            .and_then(|e| e.to_str())
331            .map(|e| e.to_ascii_lowercase());
332
333        if let Some(e) = &ext
334            && quarb_code::supported(e)
335        {
336            let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
337            return Ok(Doc::Code(a));
338        }
339        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
340            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
341            return Ok(Doc::Xlsx(a));
342        }
343        if is_sqlite(path) {
344            let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
345            return Ok(Doc::Sqlite(a));
346        }
347        if is_archive(path) {
348            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
349            return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
350        }
351
352        // Text documents.
353        let text = std::fs::read_to_string(path)
354            .with_context(|| format!("reading {}", path.display()))?;
355        let text = text
356            .strip_prefix('\u{feff}')
357            .map(str::to_owned)
358            .unwrap_or(text);
359        match ext.as_deref() {
360            Some("csv") => Doc::parse(&text, "csv"),
361            Some("tsv") => Doc::parse(&text, "tsv"),
362            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
363            Some("toml") => Doc::parse(&text, "toml"),
364            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
365            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
366            _ => {
367                if is_xml(path, &text) {
368                    Doc::parse(&text, "xml")
369                } else if is_html(path, &text) {
370                    Doc::parse(&text, "html")
371                } else {
372                    Doc::parse(&text, "json")
373                }
374            }
375        }
376    }
377
378    /// Open several sources as named children of one root (file stem =
379    /// mount name), so a single query — including a `<=>` join — spans
380    /// them all.
381    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
382        let specs: Vec<crate::MountSpec> = paths
383            .iter()
384            .map(|p| crate::MountSpec {
385                name: None,
386                path: p.clone(),
387            })
388            .collect();
389        Doc::mount_specs(&specs, opts)
390    }
391
392    /// [`Doc::mount`] with optional explicit mount names
393    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
394    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
395        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
396        for (i, spec) in specs.iter().enumerate() {
397            let name = spec.name.clone().unwrap_or_else(|| {
398                spec.path
399                    .file_stem()
400                    .map(|s| s.to_string_lossy().into_owned())
401                    .unwrap_or_else(|| format!("doc{i}"))
402            });
403            if mounts.iter().any(|m| m.name == name) {
404                bail!(
405                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
406                     same name; give each a distinct basename (or a NAME=TARGET alias)",
407                    spec.path.display()
408                );
409            }
410            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
411            mounts.push(quarb_mount::Mount { name, adapter });
412        }
413        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
414    }
415
416}
417
418/// A name-path locator built from the adapter trait alone
419/// (`parent`/`name`) — used for a mount, whose per-source render
420/// functions we do not keep.
421fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
422    let mut parts = Vec::new();
423    let mut cur = Some(node);
424    while let Some(n) = cur {
425        if let Some(nm) = a.name(n) {
426            parts.push(nm);
427        }
428        cur = a.parent(n);
429    }
430    parts.reverse();
431    format!("/{}", parts.join("/"))
432}
433
434/// Whether a file is a SQLite database — by extension, or the 16-byte
435/// header magic.
436#[cfg(feature = "native")]
437fn is_sqlite(path: &Path) -> bool {
438    if path
439        .extension()
440        .and_then(|e| e.to_str())
441        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
442    {
443        return true;
444    }
445    use std::io::Read as _;
446    let mut buf = [0u8; 16];
447    std::fs::File::open(path)
448        .and_then(|mut f| f.read_exact(&mut buf))
449        .is_ok()
450        && &buf == b"SQLite format 3\0"
451}
452
453/// Whether a file is an archive — by extension, or zip/gzip magic.
454#[cfg(feature = "native")]
455fn is_archive(path: &Path) -> bool {
456    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
457        matches!(
458            e.to_ascii_lowercase().as_str(),
459            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
460        )
461    }) {
462        return true;
463    }
464    use std::io::Read as _;
465    let mut buf = [0u8; 2];
466    std::fs::File::open(path)
467        .and_then(|mut f| f.read_exact(&mut buf))
468        .is_ok()
469        && (&buf == b"PK" || buf == [0x1f, 0x8b])
470}
471
472/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
473/// `<?xml` prolog.
474#[cfg(feature = "native")]
475fn is_xml(path: &Path, text: &str) -> bool {
476    path.extension()
477        .and_then(|e| e.to_str())
478        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
479        || text.trim_start().starts_with("<?xml")
480}
481
482/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
483/// starts with `<`.
484#[cfg(feature = "native")]
485fn is_html(path: &Path, text: &str) -> bool {
486    path.extension()
487        .and_then(|e| e.to_str())
488        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
489        || text.trim_start().starts_with('<')
490}