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