1use 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#[derive(Clone, Default)]
26pub struct Options {
27 pub hidden: bool,
28 pub respect_ignore: bool,
29 pub descend: bool,
30 pub refs: Rc<Vec<(String, String)>>,
33}
34
35pub 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 #[cfg(feature = "sqlite")]
45 Sqlite(quarb_sqlite::SqliteAdapter),
46 #[cfg(feature = "native")]
47 Fs(quarb_fs::FsAdapter),
48 #[cfg(feature = "native")]
49 FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
50 #[cfg(feature = "native")]
51 Git(quarb_git::GitAdapter),
52 #[cfg(feature = "native")]
53 Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
54 #[cfg(feature = "native")]
55 Xlsx(quarb_xlsx::XlsxAdapter),
56 #[cfg(feature = "native")]
57 Code(quarb_code::CodeAdapter),
58 Mount(quarb_mount::MountAdapter),
59 Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
63}
64
65pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
68
69impl quarb::AstAdapter for Dyn {
70 fn root(&self) -> NodeId {
71 self.0.root()
72 }
73 fn children(&self, node: NodeId) -> Vec<NodeId> {
74 self.0.children(node)
75 }
76 fn name(&self, node: NodeId) -> Option<String> {
77 self.0.name(node)
78 }
79 fn parent(&self, node: NodeId) -> Option<NodeId> {
80 self.0.parent(node)
81 }
82 fn traits(&self, node: NodeId) -> Vec<String> {
83 self.0.traits(node)
84 }
85 fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
86 self.0.property(node, name)
87 }
88 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
89 self.0.children_named(node, name)
90 }
91 fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
92 self.0.default_value(node)
93 }
94 fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
95 self.0.metadata(node, key)
96 }
97 fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
98 self.0.aliased_metadata(node)
99 }
100 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
101 self.0.links(node)
102 }
103 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
104 self.0.backlinks(node)
105 }
106 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
107 self.0.resolve(node, property, hint)
108 }
109 fn link_property(
110 &self,
111 source: NodeId,
112 label: &str,
113 target: NodeId,
114 name: &str,
115 ) -> Option<quarb::Value> {
116 self.0.link_property(source, label, target, name)
117 }
118 fn quantifier_bound(&self) -> usize {
119 self.0.quantifier_bound()
120 }
121 fn invocation_instant(&self) -> Option<(i64, u32)> {
122 self.0.invocation_instant()
123 }
124 fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
125 self.0.unit_scale(expr)
126 }
127}
128
129impl Doc {
130 fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
134 let a = std::rc::Rc::new(a);
135 let r = a.clone();
136 Doc::Boxed(
137 Dyn(Box::new(quarb_mount::Shared(a))),
138 Box::new(move |n| r.locator(n)),
139 )
140 }
141
142 pub fn parse(input: &str, format: &str) -> Result<Doc> {
146 match format {
147 "kaiv" => {
152 let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
153 .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
154 return Ok(Self::boxed_kaiv(a));
155 }
156 "daiv" => {
157 let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
158 .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
159 return Ok(Self::boxed_kaiv(a));
160 }
161 "json" => quarb_json::JsonAdapter::parse(input)
162 .map(Doc::Json)
163 .context("parsing JSON"),
164 "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
165 .map(Doc::Json)
166 .context("parsing JSONL"),
167 "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
168 "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
169 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
170 .map(Doc::Csv)
171 .context("parsing CSV"),
172 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
173 .map(Doc::Csv)
174 .context("parsing TSV"),
175 "xml" => quarb_xml::XmlAdapter::parse(input)
176 .map(Doc::Xml)
177 .context("parsing XML"),
178 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
179 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
180 "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
184 "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
185 "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
186 other => bail!("unknown format: {other}"),
187 }
188 }
189
190 pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
199 match self {
200 Doc::Json(a) => a,
201 Doc::Csv(a) => a,
202 Doc::Xml(a) => a,
203 Doc::Html(a) => a,
204 Doc::Text(a) => a,
205 #[cfg(feature = "sqlite")]
206 Doc::Sqlite(a) => a,
207 #[cfg(feature = "native")]
208 Doc::Fs(a) => a,
209 #[cfg(feature = "native")]
210 Doc::FsDeep(a) => a,
211 #[cfg(feature = "native")]
212 Doc::Git(a) => a,
213 #[cfg(feature = "native")]
214 Doc::Archive(a) => a,
215 #[cfg(feature = "native")]
216 Doc::Xlsx(a) => a,
217 #[cfg(feature = "native")]
218 Doc::Code(a) => a,
219 Doc::Mount(a) => a,
220 Doc::Boxed(a, _) => &*a.0,
221 }
222 }
223
224 pub fn export(
230 &self,
231 query: &str,
232 now: (i64, u32),
233 allow_shell: bool,
234 kind: &str,
235 ) -> Result<String> {
236 let render = quarb_text::Render::from_name(kind)
237 .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
238 if query.trim().is_empty() {
240 let base = self.base_dyn();
241 return Ok(quarb_text::render_nodes(base, &[base.root()], render));
242 }
243 match self
244 .run(query, now, allow_shell)
245 .map_err(|e| anyhow::anyhow!("{e}"))?
246 {
247 QueryResult::Nodes(nodes) => {
248 Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
249 }
250 QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
251 }
252 }
253
254 pub fn run_modeled(
259 &self,
260 query: &str,
261 now: (i64, u32),
262 allow_shell: bool,
263 model: &quarb_model::Model,
264 ) -> quarb::Result<QueryResult> {
265 let (secs, nanos) = now;
266 let base = quarb_model::Borrowed(self.base_dyn());
267 let nowed = WithNow {
268 inner: &base,
269 secs,
270 nanos,
271 };
272 let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
273 if allow_shell {
274 quarb::run(query, &AllowShell { inner: &enriched })
275 } else {
276 quarb::run(query, &enriched)
277 }
278 }
279
280 pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
283 let enriched =
284 quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
285 enriched.locator(node, |bn| self.render(bn))
286 }
287
288 pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
289 let (secs, nanos) = now;
290 macro_rules! go {
291 ($a:expr) => {{
292 let nowed = WithNow {
293 inner: $a,
294 secs,
295 nanos,
296 };
297 if allow_shell {
298 quarb::run(query, &AllowShell { inner: &nowed })
299 } else {
300 quarb::run(query, &nowed)
301 }
302 }};
303 }
304 match self {
305 Doc::Json(a) => go!(a),
306 Doc::Csv(a) => go!(a),
307 Doc::Xml(a) => go!(a),
308 Doc::Html(a) => go!(a),
309 Doc::Text(a) => go!(a),
310 #[cfg(feature = "sqlite")]
311 Doc::Sqlite(a) => go!(a),
312 #[cfg(feature = "native")]
313 Doc::Fs(a) => go!(a),
314 #[cfg(feature = "native")]
315 Doc::FsDeep(a) => go!(a),
316 #[cfg(feature = "native")]
317 Doc::Git(a) => go!(a),
318 #[cfg(feature = "native")]
319 Doc::Archive(a) => go!(a),
320 #[cfg(feature = "native")]
321 Doc::Xlsx(a) => go!(a),
322 #[cfg(feature = "native")]
323 Doc::Code(a) => go!(a),
324 Doc::Mount(a) => go!(a),
325 Doc::Boxed(a, _) => go!(a),
326 }
327 }
328
329 pub fn render(&self, node: NodeId) -> String {
331 match self {
332 Doc::Json(a) => a.pointer(node),
333 Doc::Csv(a) => a.locator(node),
334 Doc::Xml(a) => a.locator(node),
335 Doc::Html(a) => a.locator(node),
336 Doc::Text(a) => a.locator(node),
337 #[cfg(feature = "sqlite")]
338 Doc::Sqlite(a) => a.locator(node),
339 #[cfg(feature = "native")]
340 Doc::Fs(a) => a.path(node).display().to_string(),
341 #[cfg(feature = "native")]
342 Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
343 #[cfg(feature = "native")]
344 Doc::Git(a) => a.locator(node),
345 #[cfg(feature = "native")]
346 Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
347 #[cfg(feature = "native")]
348 Doc::Xlsx(a) => a.locator(node),
349 #[cfg(feature = "native")]
350 Doc::Code(a) => a.locator(node),
351 Doc::Mount(a) => generic_locator(a, node),
352 Doc::Boxed(_, render) => render(node),
353 }
354 }
355
356 #[cfg(feature = "sqlite")]
359 pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
360 Ok(Doc::Sqlite(
361 quarb_sqlite::SqliteAdapter::from_bytes(bytes)
362 .map_err(|e| anyhow::anyhow!("{e}"))
363 .context("opening SQLite bytes")?,
364 ))
365 }
366
367 #[cfg(not(feature = "sqlite"))]
371 pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
372 anyhow::bail!(
373 "this quarb-session was built without the `sqlite` feature"
374 )
375 }
376
377 pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
381 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
382 for (name, doc) in parts {
383 if mounts.iter().any(|m| m.name == name) {
384 bail!("two sources mount as '{name}'; give each a distinct name");
385 }
386 mounts.push(quarb_mount::Mount {
387 name,
388 target: None,
391 adapter: doc.into_boxed()?,
392 });
393 }
394 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
395 }
396
397 pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
402 let mut docs: Vec<(String, Doc)> = Vec::new();
403 for (name, format, text) in parts {
404 let doc = Doc::parse(text, format)
405 .with_context(|| format!("parsing '{name}' as {format}"))?;
406 docs.push((name.clone(), doc));
407 }
408 Doc::mount_docs(docs)
409 }
410
411 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
413 use quarb_mount::Shared;
414 Ok(match self {
415 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
416 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
417 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
418 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
419 Doc::Text(a) => Box::new(Shared(Rc::new(a))),
420 #[cfg(feature = "sqlite")]
421 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
422 #[cfg(feature = "native")]
423 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
424 #[cfg(feature = "native")]
425 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
426 #[cfg(feature = "native")]
427 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
428 #[cfg(feature = "native")]
429 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
430 #[cfg(feature = "native")]
431 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
432 #[cfg(feature = "native")]
433 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
434 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
435 Doc::Boxed(a, _) => a.0,
436 })
437 }
438}
439
440#[cfg(feature = "native")]
445impl Doc {
446 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
452 if path.is_dir() {
453 let fsopts = quarb_fs::FsOptions {
454 hidden: opts.hidden,
455 respect_ignore: opts.respect_ignore,
456 };
457 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
458 .with_context(|| format!("opening directory {}", path.display()))?;
459 return Ok(if opts.descend {
460 Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
461 fs,
462 |fs, n| Some(fs.path(n)),
463 ))
464 } else {
465 Doc::Fs(fs)
466 });
467 }
468
469 let s = path.to_string_lossy();
470 if let Some(repo) = s.strip_prefix("git:") {
471 let a =
472 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
473 return Ok(Doc::Git(a));
474 }
475 if let Some(rest) = s.strip_prefix("text:")
479 && !rest.is_empty()
480 {
481 let target = Path::new(rest);
482 let text = std::fs::read_to_string(target)
483 .with_context(|| format!("reading {}", target.display()))?;
484 let text = text
485 .strip_prefix('\u{feff}')
486 .map(str::to_owned)
487 .unwrap_or(text);
488 let format = match target
489 .extension()
490 .and_then(|e| e.to_str())
491 .map(|e| e.to_ascii_lowercase())
492 .as_deref()
493 {
494 Some("html" | "htm") => "text-html",
495 Some("md" | "markdown") => "text-markdown",
496 Some("txt") => "text",
497 _ if text.trim_start().starts_with('<') => "text-html",
498 _ => "text",
499 };
500 return Doc::parse(&text, format);
501 }
502
503 let ext = path
504 .extension()
505 .and_then(|e| e.to_str())
506 .map(|e| e.to_ascii_lowercase());
507
508 if let Some(e) = &ext
509 && quarb_code::supported(e)
510 {
511 let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
512 return Ok(Doc::Code(a));
513 }
514 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
515 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
516 return Ok(Doc::Xlsx(a));
517 }
518 if is_sqlite(path) {
519 #[cfg(not(feature = "sqlite"))]
523 anyhow::bail!(
524 "{}: this quarb-session was built without the `sqlite` feature",
525 path.display()
526 );
527 #[cfg(feature = "sqlite")]
528 {
529 let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
530 .context("opening SQLite database")?;
531 return Ok(Doc::Sqlite(a));
532 }
533 }
534 if is_archive(path) {
535 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
536 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
537 }
538
539 let text = std::fs::read_to_string(path)
541 .with_context(|| format!("reading {}", path.display()))?;
542 let text = text
543 .strip_prefix('\u{feff}')
544 .map(str::to_owned)
545 .unwrap_or(text);
546 match ext.as_deref() {
547 Some("csv") => Doc::parse(&text, "csv"),
548 Some("tsv") => Doc::parse(&text, "tsv"),
549 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
550 Some("toml") => Doc::parse(&text, "toml"),
551 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
552 Some("txt") => Doc::parse(&text, "text"),
553 Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
554 _ => {
555 if is_xml(path, &text) {
556 Doc::parse(&text, "xml")
557 } else if is_html(path, &text) {
558 Doc::parse(&text, "html")
559 } else {
560 Doc::parse(&text, "json")
561 }
562 }
563 }
564 }
565
566 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
570 let specs: Vec<crate::MountSpec> = paths
571 .iter()
572 .map(|p| crate::MountSpec {
573 name: None,
574 path: p.clone(),
575 })
576 .collect();
577 Doc::mount_specs(&specs, opts)
578 }
579
580 pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
583 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
584 for (i, spec) in specs.iter().enumerate() {
585 let name = spec.name.clone().unwrap_or_else(|| {
586 spec.path
587 .file_stem()
588 .map(|s| s.to_string_lossy().into_owned())
589 .unwrap_or_else(|| format!("doc{i}"))
590 });
591 if mounts.iter().any(|m| m.name == name) {
592 bail!(
593 "input '{}' mounts as '{name}', colliding with an earlier input of the \
594 same name; give each a distinct basename (or a NAME=TARGET alias)",
595 spec.path.display()
596 );
597 }
598 let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
599 mounts.push(quarb_mount::Mount {
600 name,
601 target: Some(spec.path.display().to_string()),
602 adapter,
603 });
604 }
605 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
606 }
607
608}
609
610fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
614 let mut parts = Vec::new();
615 let mut cur = Some(node);
616 while let Some(n) = cur {
617 if let Some(nm) = a.name(n) {
618 parts.push(nm);
619 }
620 cur = a.parent(n);
621 }
622 parts.reverse();
623 format!("/{}", parts.join("/"))
624}
625
626#[cfg(feature = "native")]
629fn is_sqlite(path: &Path) -> bool {
630 if path
631 .extension()
632 .and_then(|e| e.to_str())
633 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
634 {
635 return true;
636 }
637 use std::io::Read as _;
638 let mut buf = [0u8; 16];
639 std::fs::File::open(path)
640 .and_then(|mut f| f.read_exact(&mut buf))
641 .is_ok()
642 && &buf == b"SQLite format 3\0"
643}
644
645#[cfg(feature = "native")]
647fn is_archive(path: &Path) -> bool {
648 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
649 matches!(
650 e.to_ascii_lowercase().as_str(),
651 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
652 )
653 }) {
654 return true;
655 }
656 use std::io::Read as _;
657 let mut buf = [0u8; 2];
658 std::fs::File::open(path)
659 .and_then(|mut f| f.read_exact(&mut buf))
660 .is_ok()
661 && (&buf == b"PK" || buf == [0x1f, 0x8b])
662}
663
664#[cfg(feature = "native")]
667fn is_xml(path: &Path, text: &str) -> bool {
668 path.extension()
669 .and_then(|e| e.to_str())
670 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
671 || text.trim_start().starts_with("<?xml")
672}
673
674#[cfg(feature = "native")]
677fn is_html(path: &Path, text: &str) -> bool {
678 path.extension()
679 .and_then(|e| e.to_str())
680 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
681 || text.trim_start().starts_with('<')
682}