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