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::new(fs))
177            } else {
178                Doc::Fs(fs)
179            });
180        }
181
182        let s = path.to_string_lossy();
183        if let Some(repo) = s.strip_prefix("git:") {
184            let a =
185                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
186            return Ok(Doc::Git(a));
187        }
188
189        let ext = path
190            .extension()
191            .and_then(|e| e.to_str())
192            .map(|e| e.to_ascii_lowercase());
193
194        if let Some(e) = &ext
195            && quarb_code::supported(e)
196        {
197            let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
198            return Ok(Doc::Code(a));
199        }
200        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
201            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
202            return Ok(Doc::Xlsx(a));
203        }
204        if is_sqlite(path) {
205            let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
206            return Ok(Doc::Sqlite(a));
207        }
208        if is_archive(path) {
209            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
210            return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
211        }
212
213        // Text documents.
214        let text = std::fs::read_to_string(path)
215            .with_context(|| format!("reading {}", path.display()))?;
216        let text = text
217            .strip_prefix('\u{feff}')
218            .map(str::to_owned)
219            .unwrap_or(text);
220        match ext.as_deref() {
221            Some("csv") => Doc::parse(&text, "csv"),
222            Some("tsv") => Doc::parse(&text, "tsv"),
223            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
224            Some("toml") => Doc::parse(&text, "toml"),
225            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
226            _ => {
227                if is_xml(path, &text) {
228                    Doc::parse(&text, "xml")
229                } else if is_html(path, &text) {
230                    Doc::parse(&text, "html")
231                } else {
232                    Doc::parse(&text, "json")
233                }
234            }
235        }
236    }
237
238    /// Open several sources as named children of one root (file stem =
239    /// mount name), so a single query — including a `<=>` join — spans
240    /// them all.
241    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
242        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
243        for (i, p) in paths.iter().enumerate() {
244            let stem = p
245                .file_stem()
246                .map(|s| s.to_string_lossy().into_owned())
247                .unwrap_or_else(|| format!("doc{i}"));
248            if mounts.iter().any(|m| m.name == stem) {
249                bail!(
250                    "input '{}' mounts as '{stem}', colliding with an earlier input of the \
251                     same file stem; give each a distinct basename",
252                    p.display()
253                );
254            }
255            let adapter = Doc::open(p, opts)?.into_boxed()?;
256            mounts.push(quarb_mount::Mount { name: stem, adapter });
257        }
258        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
259    }
260
261    /// Box this source as a shared adapter — a mount child.
262    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
263        use quarb_mount::Shared;
264        Ok(match self {
265            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
266            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
267            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
268            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
269            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
270            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
271            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
272            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
273            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
274            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
275            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
276            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
277        })
278    }
279}
280
281/// A name-path locator built from the adapter trait alone
282/// (`parent`/`name`) — used for a mount, whose per-source render
283/// functions we do not keep.
284#[cfg(feature = "native")]
285fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
286    let mut parts = Vec::new();
287    let mut cur = Some(node);
288    while let Some(n) = cur {
289        if let Some(nm) = a.name(n) {
290            parts.push(nm);
291        }
292        cur = a.parent(n);
293    }
294    parts.reverse();
295    format!("/{}", parts.join("/"))
296}
297
298/// Whether a file is a SQLite database — by extension, or the 16-byte
299/// header magic.
300#[cfg(feature = "native")]
301fn is_sqlite(path: &Path) -> bool {
302    if path
303        .extension()
304        .and_then(|e| e.to_str())
305        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
306    {
307        return true;
308    }
309    use std::io::Read as _;
310    let mut buf = [0u8; 16];
311    std::fs::File::open(path)
312        .and_then(|mut f| f.read_exact(&mut buf))
313        .is_ok()
314        && &buf == b"SQLite format 3\0"
315}
316
317/// Whether a file is an archive — by extension, or zip/gzip magic.
318#[cfg(feature = "native")]
319fn is_archive(path: &Path) -> bool {
320    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
321        matches!(
322            e.to_ascii_lowercase().as_str(),
323            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
324        )
325    }) {
326        return true;
327    }
328    use std::io::Read as _;
329    let mut buf = [0u8; 2];
330    std::fs::File::open(path)
331        .and_then(|mut f| f.read_exact(&mut buf))
332        .is_ok()
333        && (&buf == b"PK" || buf == [0x1f, 0x8b])
334}
335
336/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
337/// `<?xml` prolog.
338#[cfg(feature = "native")]
339fn is_xml(path: &Path, text: &str) -> bool {
340    path.extension()
341        .and_then(|e| e.to_str())
342        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
343        || text.trim_start().starts_with("<?xml")
344}
345
346/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
347/// starts with `<`.
348#[cfg(feature = "native")]
349fn is_html(path: &Path, text: &str) -> bool {
350    path.extension()
351        .and_then(|e| e.to_str())
352        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
353        || text.trim_start().starts_with('<')
354}