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, Default)]
26pub struct Options {
27    pub hidden: bool,
28    pub respect_ignore: bool,
29    pub descend: bool,
30    /// Declared references, `(field, container)` pairs — the parsed
31    /// `--refs` document, consumed by the SQLite mounts.
32    pub refs: Rc<Vec<(String, String)>>,
33}
34
35/// A materialized source: one variant per adapter family. JSON-model
36/// formats (json/yaml/toml) render node results as pointers, the rest
37/// as locators.
38pub enum Doc {
39    Json(quarb_json::JsonAdapter),
40    Csv(quarb_csv::CsvAdapter),
41    Xml(quarb_xml::XmlAdapter),
42    Html(quarb_html::HtmlAdapter),
43    Text(quarb_text::TextModel),
44    Sqlite(quarb_sqlite::SqliteAdapter),
45    #[cfg(feature = "native")]
46    Fs(quarb_fs::FsAdapter),
47    #[cfg(feature = "native")]
48    FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
49    #[cfg(feature = "native")]
50    Git(quarb_git::GitAdapter),
51    #[cfg(feature = "native")]
52    Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
53    #[cfg(feature = "native")]
54    Xlsx(quarb_xlsx::XlsxAdapter),
55    #[cfg(feature = "native")]
56    Code(quarb_code::CodeAdapter),
57    Mount(quarb_mount::MountAdapter),
58    /// Any adapter behind the object-safe trait, with its locator
59    /// renderer — the carrier for scheme targets opened through
60    /// qua's dispatch (`gcl:`, `kafka:`, `neo4j://`, …).
61    Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
62}
63
64/// A boxed adapter as an adapter — plain delegation (the
65/// quarb-py `Dyn` pattern).
66pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
67
68impl quarb::AstAdapter for Dyn {
69    fn root(&self) -> NodeId {
70        self.0.root()
71    }
72    fn children(&self, node: NodeId) -> Vec<NodeId> {
73        self.0.children(node)
74    }
75    fn name(&self, node: NodeId) -> Option<String> {
76        self.0.name(node)
77    }
78    fn parent(&self, node: NodeId) -> Option<NodeId> {
79        self.0.parent(node)
80    }
81    fn traits(&self, node: NodeId) -> Vec<String> {
82        self.0.traits(node)
83    }
84    fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
85        self.0.property(node, name)
86    }
87    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
88        self.0.children_named(node, name)
89    }
90    fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
91        self.0.default_value(node)
92    }
93    fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
94        self.0.metadata(node, key)
95    }
96    fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
97        self.0.aliased_metadata(node)
98    }
99    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
100        self.0.links(node)
101    }
102    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
103        self.0.backlinks(node)
104    }
105    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
106        self.0.resolve(node, property, hint)
107    }
108    fn link_property(
109        &self,
110        source: NodeId,
111        label: &str,
112        target: NodeId,
113        name: &str,
114    ) -> Option<quarb::Value> {
115        self.0.link_property(source, label, target, name)
116    }
117    fn quantifier_bound(&self) -> usize {
118        self.0.quantifier_bound()
119    }
120    fn invocation_instant(&self) -> Option<(i64, u32)> {
121        self.0.invocation_instant()
122    }
123    fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
124        self.0.unit_scale(expr)
125    }
126}
127
128impl Doc {
129    /// The kaiv door: the adapter is Rc-shared between the Doc and
130    /// its locator renderer (the `Shared` pattern qua's scheme
131    /// mounts use), riding the Boxed variant.
132    fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
133        let a = std::rc::Rc::new(a);
134        let r = a.clone();
135        Doc::Boxed(
136            Dyn(Box::new(quarb_mount::Shared(a))),
137            Box::new(move |n| r.locator(n)),
138        )
139    }
140
141    /// Parse a text document by format name — the wasm entry point,
142    /// and the text tail of the native `open`. Formats: json, yaml,
143    /// toml, csv, tsv, xml, html, markdown, jsonl/ndjson, kaiv/daiv.
144    pub fn parse(input: &str, format: &str) -> Result<Doc> {
145        match format {
146            // kaiv rides the Boxed door (no dedicated variant): the
147            // offline resolver — a browser mount has no filesystem
148            // or registry, so `.!units`/`.!types` imports beyond the
149            // embedded core fail with kaiv's own pointed error.
150            "kaiv" => {
151                let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
152                    .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
153                return Ok(Self::boxed_kaiv(a));
154            }
155            "daiv" => {
156                let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
157                    .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
158                return Ok(Self::boxed_kaiv(a));
159            }
160            "json" => quarb_json::JsonAdapter::parse(input)
161                .map(Doc::Json)
162                .context("parsing JSON"),
163            "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
164                .map(Doc::Json)
165                .context("parsing JSONL"),
166            "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
167            "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
168            "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
169                .map(Doc::Csv)
170                .context("parsing CSV"),
171            "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
172                .map(Doc::Csv)
173                .context("parsing TSV"),
174            "xml" => quarb_xml::XmlAdapter::parse(input)
175                .map(Doc::Xml)
176                .context("parsing XML"),
177            "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
178            "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
179            // The text level: the shared section/paragraph
180            // vocabulary, produced per source format ("text" is
181            // plain text — blank-line paragraphs).
182            "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
183            "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
184            "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
185            other => bail!("unknown format: {other}"),
186        }
187    }
188
189    /// Run one query against this source with the session's invocation
190    /// instant and shell permission. The query text carries any macro
191    /// definitions inline (the session prepends its table), which
192    /// `quarb::run` expands.
193    /// The concrete adapter behind this `Doc`, as `&dyn` — the base a
194    /// `--model` enrichment layer wraps (one match, so the model
195    /// paths avoid duplicating the variant arms). Wasm-safe; the
196    /// native-only variants are compiled in only under `native`.
197    fn base_dyn(&self) -> &dyn quarb::AstAdapter {
198        match self {
199            Doc::Json(a) => a,
200            Doc::Csv(a) => a,
201            Doc::Xml(a) => a,
202            Doc::Html(a) => a,
203            Doc::Text(a) => a,
204            Doc::Sqlite(a) => a,
205            #[cfg(feature = "native")]
206            Doc::Fs(a) => a,
207            #[cfg(feature = "native")]
208            Doc::FsDeep(a) => a,
209            #[cfg(feature = "native")]
210            Doc::Git(a) => a,
211            #[cfg(feature = "native")]
212            Doc::Archive(a) => a,
213            #[cfg(feature = "native")]
214            Doc::Xlsx(a) => a,
215            #[cfg(feature = "native")]
216            Doc::Code(a) => a,
217            Doc::Mount(a) => a,
218            Doc::Boxed(a, _) => &*a.0,
219        }
220    }
221
222    /// Run `query` and render its results as exportable markup:
223    /// `md`/`markdown`, `html`, or `txt`/`text`. Node results render
224    /// structurally through the text vocabulary — sections back to
225    /// headings, lists to lists — and kinds outside it degrade to
226    /// prose paragraphs; value results render as lines.
227    pub fn export(
228        &self,
229        query: &str,
230        now: (i64, u32),
231        allow_shell: bool,
232        kind: &str,
233    ) -> Result<String> {
234        let render = quarb_text::Render::from_name(kind)
235            .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
236        // An empty query exports the whole document.
237        if query.trim().is_empty() {
238            let base = self.base_dyn();
239            return Ok(quarb_text::render_nodes(base, &[base.root()], render));
240        }
241        match self
242            .run(query, now, allow_shell)
243            .map_err(|e| anyhow::anyhow!("{e}"))?
244        {
245            QueryResult::Nodes(nodes) => {
246                Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
247            }
248            QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
249        }
250    }
251
252    /// Run against a `--model`-enriched view of this source: the
253    /// derived containers, references, and edges the model declares,
254    /// over this `Doc`'s base. `now` binds `now()` for the base and
255    /// its constructor queries alike.
256    pub fn run_modeled(
257        &self,
258        query: &str,
259        now: (i64, u32),
260        allow_shell: bool,
261        model: &quarb_model::Model,
262    ) -> quarb::Result<QueryResult> {
263        let (secs, nanos) = now;
264        let base = quarb_model::Borrowed(self.base_dyn());
265        let nowed = WithNow {
266            inner: &base,
267            secs,
268            nanos,
269        };
270        let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
271        if allow_shell {
272            quarb::run(query, &AllowShell { inner: &enriched })
273        } else {
274            quarb::run(query, &enriched)
275        }
276    }
277
278    /// Render a node from a model-enriched run: `/container/value`
279    /// for derived nodes, the base's own renderer otherwise.
280    pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
281        let enriched =
282            quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
283        enriched.locator(node, |bn| self.render(bn))
284    }
285
286    pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
287        let (secs, nanos) = now;
288        macro_rules! go {
289            ($a:expr) => {{
290                let nowed = WithNow {
291                    inner: $a,
292                    secs,
293                    nanos,
294                };
295                if allow_shell {
296                    quarb::run(query, &AllowShell { inner: &nowed })
297                } else {
298                    quarb::run(query, &nowed)
299                }
300            }};
301        }
302        match self {
303            Doc::Json(a) => go!(a),
304            Doc::Csv(a) => go!(a),
305            Doc::Xml(a) => go!(a),
306            Doc::Html(a) => go!(a),
307            Doc::Text(a) => go!(a),
308            Doc::Sqlite(a) => go!(a),
309            #[cfg(feature = "native")]
310            Doc::Fs(a) => go!(a),
311            #[cfg(feature = "native")]
312            Doc::FsDeep(a) => go!(a),
313            #[cfg(feature = "native")]
314            Doc::Git(a) => go!(a),
315            #[cfg(feature = "native")]
316            Doc::Archive(a) => go!(a),
317            #[cfg(feature = "native")]
318            Doc::Xlsx(a) => go!(a),
319            #[cfg(feature = "native")]
320            Doc::Code(a) => go!(a),
321            Doc::Mount(a) => go!(a),
322            Doc::Boxed(a, _) => go!(a),
323        }
324    }
325
326    /// Render a node result as its source-appropriate locator.
327    pub fn render(&self, node: NodeId) -> String {
328        match self {
329            Doc::Json(a) => a.pointer(node),
330            Doc::Csv(a) => a.locator(node),
331            Doc::Xml(a) => a.locator(node),
332            Doc::Html(a) => a.locator(node),
333            Doc::Text(a) => a.locator(node),
334            Doc::Sqlite(a) => a.locator(node),
335            #[cfg(feature = "native")]
336            Doc::Fs(a) => a.path(node).display().to_string(),
337            #[cfg(feature = "native")]
338            Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
339            #[cfg(feature = "native")]
340            Doc::Git(a) => a.locator(node),
341            #[cfg(feature = "native")]
342            Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
343            #[cfg(feature = "native")]
344            Doc::Xlsx(a) => a.locator(node),
345            #[cfg(feature = "native")]
346            Doc::Code(a) => a.locator(node),
347            Doc::Mount(a) => generic_locator(a, node),
348            Doc::Boxed(_, render) => render(node),
349        }
350    }
351
352    /// Open a SQLite database from its file bytes — a `.db` that
353    /// never touched a filesystem (the browser's uploaded files).
354    pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
355        Ok(Doc::Sqlite(
356            quarb_sqlite::SqliteAdapter::from_bytes(bytes)
357                .map_err(|e| anyhow::anyhow!("{e}"))
358                .context("opening SQLite bytes")?,
359        ))
360    }
361
362    /// Mount already-built documents as named children of one root —
363    /// the general wasm-safe mount, for callers that assembled their
364    /// `Doc`s from text or bytes rather than paths.
365    pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
366        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
367        for (name, doc) in parts {
368            if mounts.iter().any(|m| m.name == name) {
369                bail!("two sources mount as '{name}'; give each a distinct name");
370            }
371            mounts.push(quarb_mount::Mount {
372                name,
373                // Assembled from text/bytes: no real-world address to
374                // record — the mount name stands in for :::source.
375                target: None,
376                adapter: doc.into_boxed()?,
377            });
378        }
379        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
380    }
381
382    /// Mount several already-parsed text documents as named children
383    /// of one root — [`Doc::mount_docs`] over [`Doc::parse`], for
384    /// callers that hold text (the browser playground's paste
385    /// boxes). `parts` is `(name, format, text)`.
386    pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
387        let mut docs: Vec<(String, Doc)> = Vec::new();
388        for (name, format, text) in parts {
389            let doc = Doc::parse(text, format)
390                .with_context(|| format!("parsing '{name}' as {format}"))?;
391            docs.push((name.clone(), doc));
392        }
393        Doc::mount_docs(docs)
394    }
395
396    /// Box this source as a shared adapter — a mount child.
397    fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
398        use quarb_mount::Shared;
399        Ok(match self {
400            Doc::Json(a) => Box::new(Shared(Rc::new(a))),
401            Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
402            Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
403            Doc::Html(a) => Box::new(Shared(Rc::new(a))),
404            Doc::Text(a) => Box::new(Shared(Rc::new(a))),
405            Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
406            #[cfg(feature = "native")]
407            Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
408            #[cfg(feature = "native")]
409            Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
410            #[cfg(feature = "native")]
411            Doc::Git(a) => Box::new(Shared(Rc::new(a))),
412            #[cfg(feature = "native")]
413            Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
414            #[cfg(feature = "native")]
415            Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
416            #[cfg(feature = "native")]
417            Doc::Code(a) => Box::new(Shared(Rc::new(a))),
418            Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
419            Doc::Boxed(a, _) => a.0,
420        })
421    }
422}
423
424// ---------------------------------------------------------------------
425// Native-only: filesystem/db/git dispatch and multi-source mounts.
426// ---------------------------------------------------------------------
427
428#[cfg(feature = "native")]
429impl Doc {
430    /// Open one path as a local source. Directories are filesystem
431    /// trees (`--descend` grafts parseable leaves); `git:PATH` opens a
432    /// repository; binary kinds (SQLite, spreadsheets, archives) and
433    /// source files dispatch by extension/magic; everything else is a
434    /// text document parsed by extension or content sniff.
435    pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
436        if path.is_dir() {
437            let fsopts = quarb_fs::FsOptions {
438                hidden: opts.hidden,
439                respect_ignore: opts.respect_ignore,
440            };
441            let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
442                .with_context(|| format!("opening directory {}", path.display()))?;
443            return Ok(if opts.descend {
444                Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
445                    fs,
446                    |fs, n| Some(fs.path(n)),
447                ))
448            } else {
449                Doc::Fs(fs)
450            });
451        }
452
453        let s = path.to_string_lossy();
454        if let Some(repo) = s.strip_prefix("git:") {
455            let a =
456                quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
457            return Ok(Doc::Git(a));
458        }
459        // A `text:` prefix forces the text-level reading, matching
460        // qua's dispatch: producer by extension, `<` sniffing
461        // markup, plain paragraphs as the fallback.
462        if let Some(rest) = s.strip_prefix("text:")
463            && !rest.is_empty()
464        {
465            let target = Path::new(rest);
466            let text = std::fs::read_to_string(target)
467                .with_context(|| format!("reading {}", target.display()))?;
468            let text = text
469                .strip_prefix('\u{feff}')
470                .map(str::to_owned)
471                .unwrap_or(text);
472            let format = match target
473                .extension()
474                .and_then(|e| e.to_str())
475                .map(|e| e.to_ascii_lowercase())
476                .as_deref()
477            {
478                Some("html" | "htm") => "text-html",
479                Some("md" | "markdown") => "text-markdown",
480                Some("txt") => "text",
481                _ if text.trim_start().starts_with('<') => "text-html",
482                _ => "text",
483            };
484            return Doc::parse(&text, format);
485        }
486
487        let ext = path
488            .extension()
489            .and_then(|e| e.to_str())
490            .map(|e| e.to_ascii_lowercase());
491
492        if let Some(e) = &ext
493            && quarb_code::supported(e)
494        {
495            let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
496            return Ok(Doc::Code(a));
497        }
498        if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
499            let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
500            return Ok(Doc::Xlsx(a));
501        }
502        if is_sqlite(path) {
503            let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
504                .context("opening SQLite database")?;
505            return Ok(Doc::Sqlite(a));
506        }
507        if is_archive(path) {
508            let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
509            return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
510        }
511
512        // Text documents.
513        let text = std::fs::read_to_string(path)
514            .with_context(|| format!("reading {}", path.display()))?;
515        let text = text
516            .strip_prefix('\u{feff}')
517            .map(str::to_owned)
518            .unwrap_or(text);
519        match ext.as_deref() {
520            Some("csv") => Doc::parse(&text, "csv"),
521            Some("tsv") => Doc::parse(&text, "tsv"),
522            Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
523            Some("toml") => Doc::parse(&text, "toml"),
524            Some("md" | "markdown") => Doc::parse(&text, "markdown"),
525            Some("txt") => Doc::parse(&text, "text"),
526            Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
527            _ => {
528                if is_xml(path, &text) {
529                    Doc::parse(&text, "xml")
530                } else if is_html(path, &text) {
531                    Doc::parse(&text, "html")
532                } else {
533                    Doc::parse(&text, "json")
534                }
535            }
536        }
537    }
538
539    /// Open several sources as named children of one root (file stem =
540    /// mount name), so a single query — including a `<=>` join — spans
541    /// them all.
542    pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
543        let specs: Vec<crate::MountSpec> = paths
544            .iter()
545            .map(|p| crate::MountSpec {
546                name: None,
547                path: p.clone(),
548            })
549            .collect();
550        Doc::mount_specs(&specs, opts)
551    }
552
553    /// [`Doc::mount`] with optional explicit mount names
554    /// (`NAME=TARGET`); an unnamed spec mounts under its file stem.
555    pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
556        let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
557        for (i, spec) in specs.iter().enumerate() {
558            let name = spec.name.clone().unwrap_or_else(|| {
559                spec.path
560                    .file_stem()
561                    .map(|s| s.to_string_lossy().into_owned())
562                    .unwrap_or_else(|| format!("doc{i}"))
563            });
564            if mounts.iter().any(|m| m.name == name) {
565                bail!(
566                    "input '{}' mounts as '{name}', colliding with an earlier input of the \
567                     same name; give each a distinct basename (or a NAME=TARGET alias)",
568                    spec.path.display()
569                );
570            }
571            let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
572            mounts.push(quarb_mount::Mount {
573                name,
574                target: Some(spec.path.display().to_string()),
575                adapter,
576            });
577        }
578        Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
579    }
580
581}
582
583/// A name-path locator built from the adapter trait alone
584/// (`parent`/`name`) — used for a mount, whose per-source render
585/// functions we do not keep.
586fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
587    let mut parts = Vec::new();
588    let mut cur = Some(node);
589    while let Some(n) = cur {
590        if let Some(nm) = a.name(n) {
591            parts.push(nm);
592        }
593        cur = a.parent(n);
594    }
595    parts.reverse();
596    format!("/{}", parts.join("/"))
597}
598
599/// Whether a file is a SQLite database — by extension, or the 16-byte
600/// header magic.
601#[cfg(feature = "native")]
602fn is_sqlite(path: &Path) -> bool {
603    if path
604        .extension()
605        .and_then(|e| e.to_str())
606        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
607    {
608        return true;
609    }
610    use std::io::Read as _;
611    let mut buf = [0u8; 16];
612    std::fs::File::open(path)
613        .and_then(|mut f| f.read_exact(&mut buf))
614        .is_ok()
615        && &buf == b"SQLite format 3\0"
616}
617
618/// Whether a file is an archive — by extension, or zip/gzip magic.
619#[cfg(feature = "native")]
620fn is_archive(path: &Path) -> bool {
621    if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
622        matches!(
623            e.to_ascii_lowercase().as_str(),
624            "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
625        )
626    }) {
627        return true;
628    }
629    use std::io::Read as _;
630    let mut buf = [0u8; 2];
631    std::fs::File::open(path)
632        .and_then(|mut f| f.read_exact(&mut buf))
633        .is_ok()
634        && (&buf == b"PK" || buf == [0x1f, 0x8b])
635}
636
637/// Whether to parse as XML: an `.xml`/`.svg`/`.xhtml` name, or a
638/// `<?xml` prolog.
639#[cfg(feature = "native")]
640fn is_xml(path: &Path, text: &str) -> bool {
641    path.extension()
642        .and_then(|e| e.to_str())
643        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
644        || text.trim_start().starts_with("<?xml")
645}
646
647/// Whether to parse as HTML: an `.html`/`.htm` name, or content that
648/// starts with `<`.
649#[cfg(feature = "native")]
650fn is_html(path: &Path, text: &str) -> bool {
651    path.extension()
652        .and_then(|e| e.to_str())
653        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
654        || text.trim_start().starts_with('<')
655}