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