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::with_source_paths(
177 fs,
178 |fs, n| Some(fs.path(n)),
179 ))
180 } else {
181 Doc::Fs(fs)
182 });
183 }
184
185 let s = path.to_string_lossy();
186 if let Some(repo) = s.strip_prefix("git:") {
187 let a =
188 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
189 return Ok(Doc::Git(a));
190 }
191
192 let ext = path
193 .extension()
194 .and_then(|e| e.to_str())
195 .map(|e| e.to_ascii_lowercase());
196
197 if let Some(e) = &ext
198 && quarb_code::supported(e)
199 {
200 let a = quarb_code::CodeAdapter::open(path).context("parsing source file")?;
201 return Ok(Doc::Code(a));
202 }
203 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
204 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
205 return Ok(Doc::Xlsx(a));
206 }
207 if is_sqlite(path) {
208 let a = quarb_sqlite::SqliteAdapter::open(path).context("opening SQLite database")?;
209 return Ok(Doc::Sqlite(a));
210 }
211 if is_archive(path) {
212 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
213 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
214 }
215
216 let text = std::fs::read_to_string(path)
218 .with_context(|| format!("reading {}", path.display()))?;
219 let text = text
220 .strip_prefix('\u{feff}')
221 .map(str::to_owned)
222 .unwrap_or(text);
223 match ext.as_deref() {
224 Some("csv") => Doc::parse(&text, "csv"),
225 Some("tsv") => Doc::parse(&text, "tsv"),
226 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
227 Some("toml") => Doc::parse(&text, "toml"),
228 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
229 _ => {
230 if is_xml(path, &text) {
231 Doc::parse(&text, "xml")
232 } else if is_html(path, &text) {
233 Doc::parse(&text, "html")
234 } else {
235 Doc::parse(&text, "json")
236 }
237 }
238 }
239 }
240
241 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
245 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
246 for (i, p) in paths.iter().enumerate() {
247 let stem = p
248 .file_stem()
249 .map(|s| s.to_string_lossy().into_owned())
250 .unwrap_or_else(|| format!("doc{i}"));
251 if mounts.iter().any(|m| m.name == stem) {
252 bail!(
253 "input '{}' mounts as '{stem}', colliding with an earlier input of the \
254 same file stem; give each a distinct basename",
255 p.display()
256 );
257 }
258 let adapter = Doc::open(p, opts)?.into_boxed()?;
259 mounts.push(quarb_mount::Mount { name: stem, adapter });
260 }
261 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
262 }
263
264 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
266 use quarb_mount::Shared;
267 Ok(match self {
268 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
269 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
270 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
271 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
272 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
273 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
274 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
275 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
276 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
277 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
278 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
279 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
280 })
281 }
282}
283
284#[cfg(feature = "native")]
288fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
289 let mut parts = Vec::new();
290 let mut cur = Some(node);
291 while let Some(n) = cur {
292 if let Some(nm) = a.name(n) {
293 parts.push(nm);
294 }
295 cur = a.parent(n);
296 }
297 parts.reverse();
298 format!("/{}", parts.join("/"))
299}
300
301#[cfg(feature = "native")]
304fn is_sqlite(path: &Path) -> bool {
305 if path
306 .extension()
307 .and_then(|e| e.to_str())
308 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
309 {
310 return true;
311 }
312 use std::io::Read as _;
313 let mut buf = [0u8; 16];
314 std::fs::File::open(path)
315 .and_then(|mut f| f.read_exact(&mut buf))
316 .is_ok()
317 && &buf == b"SQLite format 3\0"
318}
319
320#[cfg(feature = "native")]
322fn is_archive(path: &Path) -> bool {
323 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
324 matches!(
325 e.to_ascii_lowercase().as_str(),
326 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
327 )
328 }) {
329 return true;
330 }
331 use std::io::Read as _;
332 let mut buf = [0u8; 2];
333 std::fs::File::open(path)
334 .and_then(|mut f| f.read_exact(&mut buf))
335 .is_ok()
336 && (&buf == b"PK" || buf == [0x1f, 0x8b])
337}
338
339#[cfg(feature = "native")]
342fn is_xml(path: &Path, text: &str) -> bool {
343 path.extension()
344 .and_then(|e| e.to_str())
345 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
346 || text.trim_start().starts_with("<?xml")
347}
348
349#[cfg(feature = "native")]
352fn is_html(path: &Path, text: &str) -> bool {
353 path.extension()
354 .and_then(|e| e.to_str())
355 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
356 || text.trim_start().starts_with('<')
357}