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 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 Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
62}
63
64pub 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 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 pub fn parse(input: &str, format: &str) -> Result<Doc> {
145 match format {
146 "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 "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 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 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 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 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 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 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 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 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 target: None,
376 adapter: doc.into_boxed()?,
377 });
378 }
379 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
380 }
381
382 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 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#[cfg(feature = "native")]
429impl Doc {
430 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 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 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 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 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
583fn 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#[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#[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#[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#[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}