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