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    #[cfg(feature = "native")]
41    Sqlite(quarb_sqlite::SqliteAdapter),
42    #[cfg(feature = "native")]
43    Fs(quarb_fs::FsAdapter),
44    #[cfg(feature = "native")]
45    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
46    #[cfg(feature = "native")]
47    Git(quarb_git::GitAdapter),
48    #[cfg(feature = "native")]
49    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
50    #[cfg(feature = "native")]
51    Xlsx(quarb_xlsx::XlsxAdapter),
52    #[cfg(feature = "native")]
53    Code(quarb_code::CodeAdapter),
54    Mount(quarb_mount::MountAdapter),
55}
56
57impl Doc {
58    /// Parse a text document by format name — the wasm entry point,
59    /// and the text tail of the native `open`. Formats: json, yaml,
60    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson.
61    pub fn parse(input: &str, format: &str) -> Result<Doc> {
62        match format {
63            "json" => quarb_json::JsonAdapter::parse(input)
64                .map(Doc::Json)
65                .context("parsing JSON"),
66            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
67                .map(Doc::Json)
68                .context("parsing JSONL"),
69            "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
70            "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
71            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
72                .map(Doc::Csv)
73                .context("parsing CSV"),
74            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
75                .map(Doc::Csv)
76                .context("parsing TSV"),
77            "xml" => quarb_xml::XmlAdapter::parse(input)
78                .map(Doc::Xml)
79                .context("parsing XML"),
80            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
81            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
82            other => bail!("unknown format: {other}"),
83        }
84    }
85
86    /// Run one query against this source with the session's invocation
87    /// instant and shell permission. The query text carries any macro
88    /// definitions inline (the session prepends its table), which
89    /// `quarb::run` expands.
90    pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
91        let (secs, nanos) = now;
92        macro_rules! go {
93            ($a:expr) => {{
94                let nowed = WithNow {
95                    inner: $a,
96                    secs,
97                    nanos,
98                };
99                if allow_shell {
100                    quarb::run(query, &AllowShell { inner: &nowed })
101                } else {
102                    quarb::run(query, &nowed)
103                }
104            }};
105        }
106        match self {
107            Doc::Json(a) => go!(a),
108            Doc::Csv(a) => go!(a),
109            Doc::Xml(a) => go!(a),
110            Doc::Html(a) => go!(a),
111            #[cfg(feature = "native")]
112            Doc::Sqlite(a) => go!(a),
113            #[cfg(feature = "native")]
114            Doc::Fs(a) => go!(a),
115            #[cfg(feature = "native")]
116            Doc::FsDeep(a) => go!(a),
117            #[cfg(feature = "native")]
118            Doc::Git(a) => go!(a),
119            #[cfg(feature = "native")]
120            Doc::Archive(a) => go!(a),
121            #[cfg(feature = "native")]
122            Doc::Xlsx(a) => go!(a),
123            #[cfg(feature = "native")]
124            Doc::Code(a) => go!(a),
125            Doc::Mount(a) => go!(a),
126        }
127    }
128
129    /// Render a node result as its source-appropriate locator.
130    pub fn render(&self, node: NodeId) -> String {
131        match self {
132            Doc::Json(a) => a.pointer(node),
133            Doc::Csv(a) => a.locator(node),
134            Doc::Xml(a) => a.locator(node),
135            Doc::Html(a) => a.locator(node),
136            #[cfg(feature = "native")]
137            Doc::Sqlite(a) => a.locator(node),
138            #[cfg(feature = "native")]
139            Doc::Fs(a) => a.path(node).display().to_string(),
140            #[cfg(feature = "native")]
141            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
142            #[cfg(feature = "native")]
143            Doc::Git(a) => a.locator(node),
144            #[cfg(feature = "native")]
145            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
146            #[cfg(feature = "native")]
147            Doc::Xlsx(a) => a.locator(node),
148            #[cfg(feature = "native")]
149            Doc::Code(a) => a.locator(node),
150            Doc::Mount(a) => generic_locator(a, node),
151        }
152    }
153
154    /// Mount several already-parsed text documents as named children
155    /// of one root — the wasm-safe face of [`Doc::mount_specs`], for
156    /// callers that hold text rather than paths (the browser
157    /// playground's uploaded files). `parts` is `(name, format,
158    /// text)`; formats are those of [`Doc::parse`].
159    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
160        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
161        for (name, format, text) in parts {
162            if mounts.iter().any(|m| &m.name == name) {
163                bail!("two sources mount as '{name}'; give each a distinct name");
164            }
165            let adapter = Doc::parse(text, format)
166                .with_context(|| format!("parsing '{name}' as {format}"))?
167                .into_boxed()?;
168            mounts.push(quarb_mount::Mount {
169                name: name.clone(),
170                adapter,
171            });
172        }
173        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
174    }
175
176    /// Box this source as a shared adapter — a mount child.
177    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
178        use quarb_mount::Shared;
179        Ok(match self {
180            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
181            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
182            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
183            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
184            #[cfg(feature = "native")]
185            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
186            #[cfg(feature = "native")]
187            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
188            #[cfg(feature = "native")]
189            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
190            #[cfg(feature = "native")]
191            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
192            #[cfg(feature = "native")]
193            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
194            #[cfg(feature = "native")]
195            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
196            #[cfg(feature = "native")]
197            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
198            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
199        })
200    }
201}
202
203// ---------------------------------------------------------------------
204// Native-only: filesystem/db/git dispatch and multi-source mounts.
205// ---------------------------------------------------------------------
206
207#[cfg(feature = "native")]
208impl Doc {
209    /// Open one path as a local source. Directories are filesystem
210    /// trees (`--descend` grafts parseable leaves); `git:PATH` opens a
211    /// repository; binary kinds (SQLite, spreadsheets, archives) and
212    /// source files dispatch by extension/magic; everything else is a
213    /// text document parsed by extension or content sniff.
214    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
215        if path.is_dir() {
216            let fsopts = quarb_fs::FsOptions {
217                hidden: opts.hidden,
218                respect_ignore: opts.respect_ignore,
219            };
220            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
221                .with_context(|| format!("opening directory {}", path.display()))?;
222            return Ok(if opts.descend {
223                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
224                    fs,
225                    |fs, n| Some(fs.path(n)),
226                ))
227            } else {
228                Doc::Fs(fs)
229            });
230        }
231
232        let s = path.to_string_lossy();
233        if let Some(repo) = s.strip_prefix("git:") {
234            let a =
235                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
236            return Ok(Doc::Git(a));
237        }
238
239        let ext = path
240            .extension()
241            .and_then(|e| e.to_str())
242            .map(|e| e.to_ascii_lowercase());
243
244        if let Some(e) = &ext
245            && quarb_code::supported(e)
246        {
247            let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
248            return Ok(Doc::Code(a));
249        }
250        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
251            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
252            return Ok(Doc::Xlsx(a));
253        }
254        if is_sqlite(path) {
255            let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
256            return Ok(Doc::Sqlite(a));
257        }
258        if is_archive(path) {
259            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
260            return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
261        }
262
263        // Text documents.
264        let text = std::fs::read_to_string(path)
265            .with_context(|| format!("reading {}", path.display()))?;
266        let text = text
267            .strip_prefix('\u{feff}')
268            .map(str::to_owned)
269            .unwrap_or(text);
270        match ext.as_deref() {
271            Some("csv") => Doc::parse(&text, "csv"),
272            Some("tsv") => Doc::parse(&text, "tsv"),
273            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
274            Some("toml") => Doc::parse(&text, "toml"),
275            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
276            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
277            _ => {
278                if is_xml(path, &text) {
279                    Doc::parse(&text, "xml")
280                } else if is_html(path, &text) {
281                    Doc::parse(&text, "html")
282                } else {
283                    Doc::parse(&text, "json")
284                }
285            }
286        }
287    }
288
289    /// Open several sources as named children of one root (file stem =
290    /// mount name), so a single query — including a `<=>` join — spans
291    /// them all.
292    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
293        let specs: Vec<crate::MountSpec> = paths
294            .iter()
295            .map(|p| crate::MountSpec {
296                name: None,
297                path: p.clone(),
298            })
299            .collect();
300        Doc::mount_specs(&specs, opts)
301    }
302
303    /// [`Doc::mount`] with optional explicit mount names
304    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
305    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
306        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
307        for (i, spec) in specs.iter().enumerate() {
308            let name = spec.name.clone().unwrap_or_else(|| {
309                spec.path
310                    .file_stem()
311                    .map(|s| s.to_string_lossy().into_owned())
312                    .unwrap_or_else(|| format!("doc{i}"))
313            });
314            if mounts.iter().any(|m| m.name == name) {
315                bail!(
316                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
317                     same name; give each a distinct basename (or a NAME=TARGET alias)",
318                    spec.path.display()
319                );
320            }
321            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
322            mounts.push(quarb_mount::Mount { name, adapter });
323        }
324        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
325    }
326
327}
328
329/// A name-path locator built from the adapter trait alone
330/// (`parent`/`name`) — used for a mount, whose per-source render
331/// functions we do not keep.
332fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
333    let mut parts = Vec::new();
334    let mut cur = Some(node);
335    while let Some(n) = cur {
336        if let Some(nm) = a.name(n) {
337            parts.push(nm);
338        }
339        cur = a.parent(n);
340    }
341    parts.reverse();
342    format!("/{}", parts.join("/"))
343}
344
345/// Whether a file is a SQLite database — by extension, or the 16-byte
346/// header magic.
347#[cfg(feature = "native")]
348fn is_sqlite(path: &Path) -> bool {
349    if path
350        .extension()
351        .and_then(|e| e.to_str())
352        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
353    {
354        return true;
355    }
356    use std::io::Read as _;
357    let mut buf = [0u8; 16];
358    std::fs::File::open(path)
359        .and_then(|mut f| f.read_exact(&mut buf))
360        .is_ok()
361        && &buf == b"SQLite format 3\0"
362}
363
364/// Whether a file is an archive — by extension, or zip/gzip magic.
365#[cfg(feature = "native")]
366fn is_archive(path: &Path) -> bool {
367    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
368        matches!(
369            e.to_ascii_lowercase().as_str(),
370            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
371        )
372    }) {
373        return true;
374    }
375    use std::io::Read as _;
376    let mut buf = [0u8; 2];
377    std::fs::File::open(path)
378        .and_then(|mut f| f.read_exact(&mut buf))
379        .is_ok()
380        && (&buf == b"PK" || buf == [0x1f, 0x8b])
381}
382
383/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
384/// `<?xml` prolog.
385#[cfg(feature = "native")]
386fn is_xml(path: &Path, text: &str) -> bool {
387    path.extension()
388        .and_then(|e| e.to_str())
389        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
390        || text.trim_start().starts_with("<?xml")
391}
392
393/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
394/// starts with `<`.
395#[cfg(feature = "native")]
396fn is_html(path: &Path, text: &str) -> bool {
397    path.extension()
398        .and_then(|e| e.to_str())
399        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
400        || text.trim_start().starts_with('<')
401}