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, Copy, Default)]
26pub struct Options {
27 pub hidden: bool,
28 pub respect_ignore: bool,
29 pub descend: bool,
30}
31
32pub enum Doc {
36 Json(quarb_json::JsonAdapter),
37 Csv(quarb_csv::CsvAdapter),
38 Xml(quarb_xml::XmlAdapter),
39 Html(quarb_html::HtmlAdapter),
40 #[cfg(feature = "native")]
41 Sqlite(quarb_sqlite::SqliteAdapter),
42 #[cfg(feature = "native")]
43 Fs(quarb_fs::FsAdapter),
44 #[cfg(feature = "native")]
45 FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
46 #[cfg(feature = "native")]
47 Git(quarb_git::GitAdapter),
48 #[cfg(feature = "native")]
49 Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
50 #[cfg(feature = "native")]
51 Xlsx(quarb_xlsx::XlsxAdapter),
52 #[cfg(feature = "native")]
53 Code(quarb_code::CodeAdapter),
54 Mount(quarb_mount::MountAdapter),
55}
56
57impl Doc {
58 pub fn parse(input: &str, format: &str) -> Result<Doc> {
62 match format {
63 "json" => quarb_json::JsonAdapter::parse(input)
64 .map(Doc::Json)
65 .context("parsing JSON"),
66 "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
67 .map(Doc::Json)
68 .context("parsing JSONL"),
69 "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
70 "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
71 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
72 .map(Doc::Csv)
73 .context("parsing CSV"),
74 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
75 .map(Doc::Csv)
76 .context("parsing TSV"),
77 "xml" => quarb_xml::XmlAdapter::parse(input)
78 .map(Doc::Xml)
79 .context("parsing XML"),
80 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
81 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
82 other => bail!("unknown format: {other}"),
83 }
84 }
85
86 pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
91 let (secs, nanos) = now;
92 macro_rules! go {
93 ($a:expr) => {{
94 let nowed = WithNow {
95 inner: $a,
96 secs,
97 nanos,
98 };
99 if allow_shell {
100 quarb::run(query, &AllowShell { inner: &nowed })
101 } else {
102 quarb::run(query, &nowed)
103 }
104 }};
105 }
106 match self {
107 Doc::Json(a) => go!(a),
108 Doc::Csv(a) => go!(a),
109 Doc::Xml(a) => go!(a),
110 Doc::Html(a) => go!(a),
111 #[cfg(feature = "native")]
112 Doc::Sqlite(a) => go!(a),
113 #[cfg(feature = "native")]
114 Doc::Fs(a) => go!(a),
115 #[cfg(feature = "native")]
116 Doc::FsDeep(a) => go!(a),
117 #[cfg(feature = "native")]
118 Doc::Git(a) => go!(a),
119 #[cfg(feature = "native")]
120 Doc::Archive(a) => go!(a),
121 #[cfg(feature = "native")]
122 Doc::Xlsx(a) => go!(a),
123 #[cfg(feature = "native")]
124 Doc::Code(a) => go!(a),
125 Doc::Mount(a) => go!(a),
126 }
127 }
128
129 pub fn render(&self, node: NodeId) -> String {
131 match self {
132 Doc::Json(a) => a.pointer(node),
133 Doc::Csv(a) => a.locator(node),
134 Doc::Xml(a) => a.locator(node),
135 Doc::Html(a) => a.locator(node),
136 #[cfg(feature = "native")]
137 Doc::Sqlite(a) => a.locator(node),
138 #[cfg(feature = "native")]
139 Doc::Fs(a) => a.path(node).display().to_string(),
140 #[cfg(feature = "native")]
141 Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
142 #[cfg(feature = "native")]
143 Doc::Git(a) => a.locator(node),
144 #[cfg(feature = "native")]
145 Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
146 #[cfg(feature = "native")]
147 Doc::Xlsx(a) => a.locator(node),
148 #[cfg(feature = "native")]
149 Doc::Code(a) => a.locator(node),
150 Doc::Mount(a) => generic_locator(a, node),
151 }
152 }
153
154 pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
160 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
161 for (name, format, text) in parts {
162 if mounts.iter().any(|m| &m.name == name) {
163 bail!("two sources mount as '{name}'; give each a distinct name");
164 }
165 let adapter = Doc::parse(text, format)
166 .with_context(|| format!("parsing '{name}' as {format}"))?
167 .into_boxed()?;
168 mounts.push(quarb_mount::Mount {
169 name: name.clone(),
170 adapter,
171 });
172 }
173 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
174 }
175
176 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
178 use quarb_mount::Shared;
179 Ok(match self {
180 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
181 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
182 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
183 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
184 #[cfg(feature = "native")]
185 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
186 #[cfg(feature = "native")]
187 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
188 #[cfg(feature = "native")]
189 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
190 #[cfg(feature = "native")]
191 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
192 #[cfg(feature = "native")]
193 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
194 #[cfg(feature = "native")]
195 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
196 #[cfg(feature = "native")]
197 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
198 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
199 })
200 }
201}
202
203#[cfg(feature = "native")]
208impl Doc {
209 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
215 if path.is_dir() {
216 let fsopts = quarb_fs::FsOptions {
217 hidden: opts.hidden,
218 respect_ignore: opts.respect_ignore,
219 };
220 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
221 .with_context(|| format!("opening directory {}", path.display()))?;
222 return Ok(if opts.descend {
223 Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
224 fs,
225 |fs, n| Some(fs.path(n)),
226 ))
227 } else {
228 Doc::Fs(fs)
229 });
230 }
231
232 let s = path.to_string_lossy();
233 if let Some(repo) = s.strip_prefix("git:") {
234 let a =
235 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
236 return Ok(Doc::Git(a));
237 }
238
239 let ext = path
240 .extension()
241 .and_then(|e| e.to_str())
242 .map(|e| e.to_ascii_lowercase());
243
244 if let Some(e) = &ext
245 && quarb_code::supported(e)
246 {
247 let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
248 return Ok(Doc::Code(a));
249 }
250 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
251 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
252 return Ok(Doc::Xlsx(a));
253 }
254 if is_sqlite(path) {
255 let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
256 return Ok(Doc::Sqlite(a));
257 }
258 if is_archive(path) {
259 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
260 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
261 }
262
263 let text = std::fs::read_to_string(path)
265 .with_context(|| format!("reading {}", path.display()))?;
266 let text = text
267 .strip_prefix('\u{feff}')
268 .map(str::to_owned)
269 .unwrap_or(text);
270 match ext.as_deref() {
271 Some("csv") => Doc::parse(&text, "csv"),
272 Some("tsv") => Doc::parse(&text, "tsv"),
273 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
274 Some("toml") => Doc::parse(&text, "toml"),
275 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
276 Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
277 _ => {
278 if is_xml(path, &text) {
279 Doc::parse(&text, "xml")
280 } else if is_html(path, &text) {
281 Doc::parse(&text, "html")
282 } else {
283 Doc::parse(&text, "json")
284 }
285 }
286 }
287 }
288
289 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
293 let specs: Vec<crate::MountSpec> = paths
294 .iter()
295 .map(|p| crate::MountSpec {
296 name: None,
297 path: p.clone(),
298 })
299 .collect();
300 Doc::mount_specs(&specs, opts)
301 }
302
303 pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
306 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
307 for (i, spec) in specs.iter().enumerate() {
308 let name = spec.name.clone().unwrap_or_else(|| {
309 spec.path
310 .file_stem()
311 .map(|s| s.to_string_lossy().into_owned())
312 .unwrap_or_else(|| format!("doc{i}"))
313 });
314 if mounts.iter().any(|m| m.name == name) {
315 bail!(
316 "input '{}' mounts as '{name}', colliding with an earlier input of the \
317 same name; give each a distinct basename (or a NAME=TARGET alias)",
318 spec.path.display()
319 );
320 }
321 let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
322 mounts.push(quarb_mount::Mount { name, adapter });
323 }
324 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
325 }
326
327}
328
329fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
333 let mut parts = Vec::new();
334 let mut cur = Some(node);
335 while let Some(n) = cur {
336 if let Some(nm) = a.name(n) {
337 parts.push(nm);
338 }
339 cur = a.parent(n);
340 }
341 parts.reverse();
342 format!("/{}", parts.join("/"))
343}
344
345#[cfg(feature = "native")]
348fn is_sqlite(path: &Path) -> bool {
349 if path
350 .extension()
351 .and_then(|e| e.to_str())
352 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
353 {
354 return true;
355 }
356 use std::io::Read as _;
357 let mut buf = [0u8; 16];
358 std::fs::File::open(path)
359 .and_then(|mut f| f.read_exact(&mut buf))
360 .is_ok()
361 && &buf == b"SQLite format 3\0"
362}
363
364#[cfg(feature = "native")]
366fn is_archive(path: &Path) -> bool {
367 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
368 matches!(
369 e.to_ascii_lowercase().as_str(),
370 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
371 )
372 }) {
373 return true;
374 }
375 use std::io::Read as _;
376 let mut buf = [0u8; 2];
377 std::fs::File::open(path)
378 .and_then(|mut f| f.read_exact(&mut buf))
379 .is_ok()
380 && (&buf == b"PK" || buf == [0x1f, 0x8b])
381}
382
383#[cfg(feature = "native")]
386fn is_xml(path: &Path, text: &str) -> bool {
387 path.extension()
388 .and_then(|e| e.to_str())
389 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
390 || text.trim_start().starts_with("<?xml")
391}
392
393#[cfg(feature = "native")]
396fn is_html(path: &Path, text: &str) -> bool {
397 path.extension()
398 .and_then(|e| e.to_str())
399 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
400 || text.trim_start().starts_with('<')
401}