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 Sqlite(quarb_sqlite::SqliteAdapter),
44 #[cfg(feature = "native")]
45 Fs(quarb_fs::FsAdapter),
46 #[cfg(feature = "native")]
47 FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
48 #[cfg(feature = "native")]
49 Git(quarb_git::GitAdapter),
50 #[cfg(feature = "native")]
51 Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
52 #[cfg(feature = "native")]
53 Xlsx(quarb_xlsx::XlsxAdapter),
54 #[cfg(feature = "native")]
55 Code(quarb_code::CodeAdapter),
56 Mount(quarb_mount::MountAdapter),
57 Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
61}
62
63pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
66
67impl quarb::AstAdapter for Dyn {
68 fn root(&self) -> NodeId {
69 self.0.root()
70 }
71 fn children(&self, node: NodeId) -> Vec<NodeId> {
72 self.0.children(node)
73 }
74 fn name(&self, node: NodeId) -> Option<String> {
75 self.0.name(node)
76 }
77 fn parent(&self, node: NodeId) -> Option<NodeId> {
78 self.0.parent(node)
79 }
80 fn traits(&self, node: NodeId) -> Vec<String> {
81 self.0.traits(node)
82 }
83 fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
84 self.0.property(node, name)
85 }
86 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
87 self.0.children_named(node, name)
88 }
89 fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
90 self.0.default_value(node)
91 }
92 fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
93 self.0.metadata(node, key)
94 }
95 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
96 self.0.links(node)
97 }
98 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
99 self.0.backlinks(node)
100 }
101 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
102 self.0.resolve(node, property, hint)
103 }
104 fn link_property(
105 &self,
106 source: NodeId,
107 label: &str,
108 target: NodeId,
109 name: &str,
110 ) -> Option<quarb::Value> {
111 self.0.link_property(source, label, target, name)
112 }
113 fn quantifier_bound(&self) -> usize {
114 self.0.quantifier_bound()
115 }
116 fn invocation_instant(&self) -> Option<(i64, u32)> {
117 self.0.invocation_instant()
118 }
119 fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
120 self.0.unit_scale(expr)
121 }
122}
123
124impl Doc {
125 fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
129 let a = std::rc::Rc::new(a);
130 let r = a.clone();
131 Doc::Boxed(
132 Dyn(Box::new(quarb_mount::Shared(a))),
133 Box::new(move |n| r.locator(n)),
134 )
135 }
136
137 pub fn parse(input: &str, format: &str) -> Result<Doc> {
141 match format {
142 "kaiv" => {
147 let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
148 .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
149 return Ok(Self::boxed_kaiv(a));
150 }
151 "daiv" => {
152 let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
153 .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
154 return Ok(Self::boxed_kaiv(a));
155 }
156 "json" => quarb_json::JsonAdapter::parse(input)
157 .map(Doc::Json)
158 .context("parsing JSON"),
159 "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
160 .map(Doc::Json)
161 .context("parsing JSONL"),
162 "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
163 "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
164 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
165 .map(Doc::Csv)
166 .context("parsing CSV"),
167 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
168 .map(Doc::Csv)
169 .context("parsing TSV"),
170 "xml" => quarb_xml::XmlAdapter::parse(input)
171 .map(Doc::Xml)
172 .context("parsing XML"),
173 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
174 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
175 other => bail!("unknown format: {other}"),
176 }
177 }
178
179 fn base_dyn(&self) -> &dyn quarb::AstAdapter {
188 match self {
189 Doc::Json(a) => a,
190 Doc::Csv(a) => a,
191 Doc::Xml(a) => a,
192 Doc::Html(a) => a,
193 Doc::Sqlite(a) => a,
194 #[cfg(feature = "native")]
195 Doc::Fs(a) => a,
196 #[cfg(feature = "native")]
197 Doc::FsDeep(a) => a,
198 #[cfg(feature = "native")]
199 Doc::Git(a) => a,
200 #[cfg(feature = "native")]
201 Doc::Archive(a) => a,
202 #[cfg(feature = "native")]
203 Doc::Xlsx(a) => a,
204 #[cfg(feature = "native")]
205 Doc::Code(a) => a,
206 Doc::Mount(a) => a,
207 Doc::Boxed(a, _) => &*a.0,
208 }
209 }
210
211 pub fn run_modeled(
216 &self,
217 query: &str,
218 now: (i64, u32),
219 allow_shell: bool,
220 model: &quarb_model::Model,
221 ) -> quarb::Result<QueryResult> {
222 let (secs, nanos) = now;
223 let base = quarb_model::Borrowed(self.base_dyn());
224 let nowed = WithNow {
225 inner: &base,
226 secs,
227 nanos,
228 };
229 let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
230 if allow_shell {
231 quarb::run(query, &AllowShell { inner: &enriched })
232 } else {
233 quarb::run(query, &enriched)
234 }
235 }
236
237 pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
240 let enriched =
241 quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
242 enriched.locator(node, |bn| self.render(bn))
243 }
244
245 pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
246 let (secs, nanos) = now;
247 macro_rules! go {
248 ($a:expr) => {{
249 let nowed = WithNow {
250 inner: $a,
251 secs,
252 nanos,
253 };
254 if allow_shell {
255 quarb::run(query, &AllowShell { inner: &nowed })
256 } else {
257 quarb::run(query, &nowed)
258 }
259 }};
260 }
261 match self {
262 Doc::Json(a) => go!(a),
263 Doc::Csv(a) => go!(a),
264 Doc::Xml(a) => go!(a),
265 Doc::Html(a) => go!(a),
266 Doc::Sqlite(a) => go!(a),
267 #[cfg(feature = "native")]
268 Doc::Fs(a) => go!(a),
269 #[cfg(feature = "native")]
270 Doc::FsDeep(a) => go!(a),
271 #[cfg(feature = "native")]
272 Doc::Git(a) => go!(a),
273 #[cfg(feature = "native")]
274 Doc::Archive(a) => go!(a),
275 #[cfg(feature = "native")]
276 Doc::Xlsx(a) => go!(a),
277 #[cfg(feature = "native")]
278 Doc::Code(a) => go!(a),
279 Doc::Mount(a) => go!(a),
280 Doc::Boxed(a, _) => go!(a),
281 }
282 }
283
284 pub fn render(&self, node: NodeId) -> String {
286 match self {
287 Doc::Json(a) => a.pointer(node),
288 Doc::Csv(a) => a.locator(node),
289 Doc::Xml(a) => a.locator(node),
290 Doc::Html(a) => a.locator(node),
291 Doc::Sqlite(a) => a.locator(node),
292 #[cfg(feature = "native")]
293 Doc::Fs(a) => a.path(node).display().to_string(),
294 #[cfg(feature = "native")]
295 Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
296 #[cfg(feature = "native")]
297 Doc::Git(a) => a.locator(node),
298 #[cfg(feature = "native")]
299 Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
300 #[cfg(feature = "native")]
301 Doc::Xlsx(a) => a.locator(node),
302 #[cfg(feature = "native")]
303 Doc::Code(a) => a.locator(node),
304 Doc::Mount(a) => generic_locator(a, node),
305 Doc::Boxed(_, render) => render(node),
306 }
307 }
308
309 pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
312 Ok(Doc::Sqlite(
313 quarb_sqlite::SqliteAdapter::from_bytes(bytes)
314 .map_err(|e| anyhow::anyhow!("{e}"))
315 .context("opening SQLite bytes")?,
316 ))
317 }
318
319 pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
323 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
324 for (name, doc) in parts {
325 if mounts.iter().any(|m| m.name == name) {
326 bail!("two sources mount as '{name}'; give each a distinct name");
327 }
328 mounts.push(quarb_mount::Mount {
329 name,
330 target: None,
333 adapter: doc.into_boxed()?,
334 });
335 }
336 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
337 }
338
339 pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
344 let mut docs: Vec<(String, Doc)> = Vec::new();
345 for (name, format, text) in parts {
346 let doc = Doc::parse(text, format)
347 .with_context(|| format!("parsing '{name}' as {format}"))?;
348 docs.push((name.clone(), doc));
349 }
350 Doc::mount_docs(docs)
351 }
352
353 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
355 use quarb_mount::Shared;
356 Ok(match self {
357 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
358 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
359 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
360 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
361 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
362 #[cfg(feature = "native")]
363 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
364 #[cfg(feature = "native")]
365 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
366 #[cfg(feature = "native")]
367 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
368 #[cfg(feature = "native")]
369 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
370 #[cfg(feature = "native")]
371 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
372 #[cfg(feature = "native")]
373 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
374 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
375 Doc::Boxed(a, _) => a.0,
376 })
377 }
378}
379
380#[cfg(feature = "native")]
385impl Doc {
386 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
392 if path.is_dir() {
393 let fsopts = quarb_fs::FsOptions {
394 hidden: opts.hidden,
395 respect_ignore: opts.respect_ignore,
396 };
397 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
398 .with_context(|| format!("opening directory {}", path.display()))?;
399 return Ok(if opts.descend {
400 Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
401 fs,
402 |fs, n| Some(fs.path(n)),
403 ))
404 } else {
405 Doc::Fs(fs)
406 });
407 }
408
409 let s = path.to_string_lossy();
410 if let Some(repo) = s.strip_prefix("git:") {
411 let a =
412 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
413 return Ok(Doc::Git(a));
414 }
415
416 let ext = path
417 .extension()
418 .and_then(|e| e.to_str())
419 .map(|e| e.to_ascii_lowercase());
420
421 if let Some(e) = &ext
422 && quarb_code::supported(e)
423 {
424 let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
425 return Ok(Doc::Code(a));
426 }
427 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
428 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
429 return Ok(Doc::Xlsx(a));
430 }
431 if is_sqlite(path) {
432 let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
433 .context("opening SQLite database")?;
434 return Ok(Doc::Sqlite(a));
435 }
436 if is_archive(path) {
437 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
438 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
439 }
440
441 let text = std::fs::read_to_string(path)
443 .with_context(|| format!("reading {}", path.display()))?;
444 let text = text
445 .strip_prefix('\u{feff}')
446 .map(str::to_owned)
447 .unwrap_or(text);
448 match ext.as_deref() {
449 Some("csv") => Doc::parse(&text, "csv"),
450 Some("tsv") => Doc::parse(&text, "tsv"),
451 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
452 Some("toml") => Doc::parse(&text, "toml"),
453 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
454 Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
455 _ => {
456 if is_xml(path, &text) {
457 Doc::parse(&text, "xml")
458 } else if is_html(path, &text) {
459 Doc::parse(&text, "html")
460 } else {
461 Doc::parse(&text, "json")
462 }
463 }
464 }
465 }
466
467 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
471 let specs: Vec<crate::MountSpec> = paths
472 .iter()
473 .map(|p| crate::MountSpec {
474 name: None,
475 path: p.clone(),
476 })
477 .collect();
478 Doc::mount_specs(&specs, opts)
479 }
480
481 pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
484 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
485 for (i, spec) in specs.iter().enumerate() {
486 let name = spec.name.clone().unwrap_or_else(|| {
487 spec.path
488 .file_stem()
489 .map(|s| s.to_string_lossy().into_owned())
490 .unwrap_or_else(|| format!("doc{i}"))
491 });
492 if mounts.iter().any(|m| m.name == name) {
493 bail!(
494 "input '{}' mounts as '{name}', colliding with an earlier input of the \
495 same name; give each a distinct basename (or a NAME=TARGET alias)",
496 spec.path.display()
497 );
498 }
499 let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
500 mounts.push(quarb_mount::Mount {
501 name,
502 target: Some(spec.path.display().to_string()),
503 adapter,
504 });
505 }
506 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
507 }
508
509}
510
511fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
515 let mut parts = Vec::new();
516 let mut cur = Some(node);
517 while let Some(n) = cur {
518 if let Some(nm) = a.name(n) {
519 parts.push(nm);
520 }
521 cur = a.parent(n);
522 }
523 parts.reverse();
524 format!("/{}", parts.join("/"))
525}
526
527#[cfg(feature = "native")]
530fn is_sqlite(path: &Path) -> bool {
531 if path
532 .extension()
533 .and_then(|e| e.to_str())
534 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
535 {
536 return true;
537 }
538 use std::io::Read as _;
539 let mut buf = [0u8; 16];
540 std::fs::File::open(path)
541 .and_then(|mut f| f.read_exact(&mut buf))
542 .is_ok()
543 && &buf == b"SQLite format 3\0"
544}
545
546#[cfg(feature = "native")]
548fn is_archive(path: &Path) -> bool {
549 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
550 matches!(
551 e.to_ascii_lowercase().as_str(),
552 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
553 )
554 }) {
555 return true;
556 }
557 use std::io::Read as _;
558 let mut buf = [0u8; 2];
559 std::fs::File::open(path)
560 .and_then(|mut f| f.read_exact(&mut buf))
561 .is_ok()
562 && (&buf == b"PK" || buf == [0x1f, 0x8b])
563}
564
565#[cfg(feature = "native")]
568fn is_xml(path: &Path, text: &str) -> bool {
569 path.extension()
570 .and_then(|e| e.to_str())
571 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
572 || text.trim_start().starts_with("<?xml")
573}
574
575#[cfg(feature = "native")]
578fn is_html(path: &Path, text: &str) -> bool {
579 path.extension()
580 .and_then(|e| e.to_str())
581 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
582 || text.trim_start().starts_with('<')
583}