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 "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
69 "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
70 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
71 .map(Doc::Csv)
72 .context("parsing CSV"),
73 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
74 .map(Doc::Csv)
75 .context("parsing TSV"),
76 "xml" => quarb_xml::XmlAdapter::parse(input)
77 .map(Doc::Xml)
78 .context("parsing XML"),
79 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
80 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
81 other => bail!("unknown format: {other}"),
82 }
83 }
84
85 pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
90 let (secs, nanos) = now;
91 macro_rules! go {
92 ($a:expr) => {{
93 let nowed = WithNow {
94 inner: $a,
95 secs,
96 nanos,
97 };
98 if allow_shell {
99 quarb::run(query, &AllowShell { inner: &nowed })
100 } else {
101 quarb::run(query, &nowed)
102 }
103 }};
104 }
105 match self {
106 Doc::Json(a) => go!(a),
107 Doc::Csv(a) => go!(a),
108 Doc::Xml(a) => go!(a),
109 Doc::Html(a) => go!(a),
110 #[cfg(feature = "native")]
111 Doc::Sqlite(a) => go!(a),
112 #[cfg(feature = "native")]
113 Doc::Fs(a) => go!(a),
114 #[cfg(feature = "native")]
115 Doc::FsDeep(a) => go!(a),
116 #[cfg(feature = "native")]
117 Doc::Git(a) => go!(a),
118 #[cfg(feature = "native")]
119 Doc::Archive(a) => go!(a),
120 #[cfg(feature = "native")]
121 Doc::Xlsx(a) => go!(a),
122 #[cfg(feature = "native")]
123 Doc::Code(a) => go!(a),
124 #[cfg(feature = "native")]
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 #[cfg(feature = "native")]
151 Doc::Mount(a) => generic_locator(a, node),
152 }
153 }
154}
155
156#[cfg(feature = "native")]
161impl Doc {
162 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
168 if path.is_dir() {
169 let fsopts = quarb_fs::FsOptions {
170 hidden: opts.hidden,
171 respect_ignore: opts.respect_ignore,
172 };
173 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
174 .with_context(|| format!("opening directory {}", path.display()))?;
175 return Ok(if opts.descend {
176 Doc::FsDeep(quarb_compose::ComposeAdapter::new(fs))
177 } else {
178 Doc::Fs(fs)
179 });
180 }
181
182 let s = path.to_string_lossy();
183 if let Some(repo) = s.strip_prefix("git:") {
184 let a =
185 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
186 return Ok(Doc::Git(a));
187 }
188
189 let ext = path
190 .extension()
191 .and_then(|e| e.to_str())
192 .map(|e| e.to_ascii_lowercase());
193
194 if let Some(e) = &ext
195 && quarb_code::supported(e)
196 {
197 let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
198 return Ok(Doc::Code(a));
199 }
200 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
201 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
202 return Ok(Doc::Xlsx(a));
203 }
204 if is_sqlite(path) {
205 let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
206 return Ok(Doc::Sqlite(a));
207 }
208 if is_archive(path) {
209 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
210 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
211 }
212
213 let text = std::fs::read_to_string(path)
215 .with_context(|| format!("reading {}", path.display()))?;
216 let text = text
217 .strip_prefix('\u{feff}')
218 .map(str::to_owned)
219 .unwrap_or(text);
220 match ext.as_deref() {
221 Some("csv") => Doc::parse(&text, "csv"),
222 Some("tsv") => Doc::parse(&text, "tsv"),
223 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
224 Some("toml") => Doc::parse(&text, "toml"),
225 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
226 _ => {
227 if is_xml(path, &text) {
228 Doc::parse(&text, "xml")
229 } else if is_html(path, &text) {
230 Doc::parse(&text, "html")
231 } else {
232 Doc::parse(&text, "json")
233 }
234 }
235 }
236 }
237
238 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
242 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
243 for (i, p) in paths.iter().enumerate() {
244 let stem = p
245 .file_stem()
246 .map(|s| s.to_string_lossy().into_owned())
247 .unwrap_or_else(|| format!("doc{i}"));
248 if mounts.iter().any(|m| m.name == stem) {
249 bail!(
250 "input '{}' mounts as '{stem}', colliding with an earlier input of the \
251 same file stem; give each a distinct basename",
252 p.display()
253 );
254 }
255 let adapter = Doc::open(p, opts)?.into_boxed()?;
256 mounts.push(quarb_mount::Mount { name: stem, adapter });
257 }
258 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
259 }
260
261 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
263 use quarb_mount::Shared;
264 Ok(match self {
265 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
266 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
267 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
268 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
269 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
270 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
271 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
272 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
273 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
274 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
275 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
276 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
277 })
278 }
279}
280
281#[cfg(feature = "native")]
285fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
286 let mut parts = Vec::new();
287 let mut cur = Some(node);
288 while let Some(n) = cur {
289 if let Some(nm) = a.name(n) {
290 parts.push(nm);
291 }
292 cur = a.parent(n);
293 }
294 parts.reverse();
295 format!("/{}", parts.join("/"))
296}
297
298#[cfg(feature = "native")]
301fn is_sqlite(path: &Path) -> bool {
302 if path
303 .extension()
304 .and_then(|e| e.to_str())
305 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
306 {
307 return true;
308 }
309 use std::io::Read as _;
310 let mut buf = [0u8; 16];
311 std::fs::File::open(path)
312 .and_then(|mut f| f.read_exact(&mut buf))
313 .is_ok()
314 && &buf == b"SQLite format 3\0"
315}
316
317#[cfg(feature = "native")]
319fn is_archive(path: &Path) -> bool {
320 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
321 matches!(
322 e.to_ascii_lowercase().as_str(),
323 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
324 )
325 }) {
326 return true;
327 }
328 use std::io::Read as _;
329 let mut buf = [0u8; 2];
330 std::fs::File::open(path)
331 .and_then(|mut f| f.read_exact(&mut buf))
332 .is_ok()
333 && (&buf == b"PK" || buf == [0x1f, 0x8b])
334}
335
336#[cfg(feature = "native")]
339fn is_xml(path: &Path, text: &str) -> bool {
340 path.extension()
341 .and_then(|e| e.to_str())
342 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
343 || text.trim_start().starts_with("<?xml")
344}
345
346#[cfg(feature = "native")]
349fn is_html(path: &Path, text: &str) -> bool {
350 path.extension()
351 .and_then(|e| e.to_str())
352 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
353 || text.trim_start().starts_with('<')
354}