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 graft: bool,
31 pub no_graft: bool,
33 pub refs: Rc<Vec<(String, String)>>,
36 pub allow_shell: bool,
39 pub bonds: Option<Rc<syndesmos::Syndesmos>>,
42}
43
44pub enum Doc {
48 Json(quarb_json::JsonAdapter),
49 Csv(quarb_csv::CsvAdapter),
50 Xml(quarb_xml::XmlAdapter),
51 Html(quarb_html::HtmlAdapter),
52 Text(quarb_text::TextModel),
53 #[cfg(feature = "sqlite")]
54 Sqlite(quarb_sqlite::SqliteAdapter),
55 #[cfg(feature = "native")]
56 Fs(quarb_fs::FsAdapter),
57 #[cfg(feature = "native")]
58 FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
59 #[cfg(feature = "native")]
60 Git(quarb_git::GitAdapter),
61 #[cfg(feature = "native")]
62 Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
63 #[cfg(feature = "native")]
66 ArchiveRaw(quarb_archive::ArchiveAdapter),
67 #[cfg(feature = "native")]
68 Xlsx(quarb_xlsx::XlsxAdapter),
69 #[cfg(feature = "native")]
70 Syntax(quarb_tree_sitter::TreeSitterAdapter),
71 #[cfg(feature = "native")]
72 Code(quarb_code::CodeModel),
73 Mount(quarb_mount::MountAdapter),
74 Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
78}
79
80pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
83
84impl quarb::AstAdapter for Dyn {
85 fn root(&self) -> NodeId {
86 self.0.root()
87 }
88 fn children(&self, node: NodeId) -> Vec<NodeId> {
89 self.0.children(node)
90 }
91 fn name(&self, node: NodeId) -> Option<String> {
92 self.0.name(node)
93 }
94 fn parent(&self, node: NodeId) -> Option<NodeId> {
95 self.0.parent(node)
96 }
97 fn traits(&self, node: NodeId) -> Vec<String> {
98 self.0.traits(node)
99 }
100 fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
101 self.0.property(node, name)
102 }
103 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
104 self.0.children_named(node, name)
105 }
106 fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
107 self.0.default_value(node)
108 }
109 fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
110 self.0.metadata(node, key)
111 }
112 fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
113 self.0.aliased_metadata(node)
114 }
115 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
116 self.0.links(node)
117 }
118 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
119 self.0.backlinks(node)
120 }
121 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
122 self.0.resolve(node, property, hint)
123 }
124 fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
125 self.0.external_ref(node, property, hint)
126 }
127 fn resolve_fragment(&self, node: NodeId, fragment: &str) -> Option<NodeId> {
128 self.0.resolve_fragment(node, fragment)
129 }
130 fn ref_property(&self, node: NodeId) -> Option<String> {
131 self.0.ref_property(node)
132 }
133 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
134 self.0.ref_label(node, property)
135 }
136 fn link_property(
137 &self,
138 source: NodeId,
139 label: &str,
140 target: NodeId,
141 name: &str,
142 ) -> Option<quarb::Value> {
143 self.0.link_property(source, label, target, name)
144 }
145 fn quantifier_bound(&self) -> usize {
146 self.0.quantifier_bound()
147 }
148 fn invocation_instant(&self) -> Option<(i64, u32)> {
149 self.0.invocation_instant()
150 }
151 fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
152 self.0.unit_scale(expr)
153 }
154 fn answers_to(&self, node: NodeId, name: &str) -> bool {
159 self.0.answers_to(node, name)
160 }
161 fn has_trait(&self, node: NodeId, name: &str) -> bool {
162 self.0.has_trait(node, name)
163 }
164 fn has_node(&self, node: NodeId) -> bool {
165 self.0.has_node(node)
166 }
167 fn document_by_ref(&self, id: &str) -> Option<NodeId> {
168 self.0.document_by_ref(id)
169 }
170 fn reverse_resolve(
171 &self,
172 node: NodeId,
173 property: Option<&str>,
174 hint: Option<&str>,
175 ) -> Option<Vec<NodeId>> {
176 self.0.reverse_resolve(node, property, hint)
177 }
178 fn descendants_named(&self, node: NodeId, name: &str) -> Option<Vec<(NodeId, usize)>> {
179 self.0.descendants_named(node, name)
180 }
181 fn prefetch_links(&self, nodes: &[NodeId], dir: quarb::LinkDir) {
182 self.0.prefetch_links(nodes, dir)
183 }
184 fn provenance(&self, node: NodeId) -> quarb::Provenance {
185 self.0.provenance(node)
186 }
187 fn provenance_list(&self, node: NodeId) -> quarb::ProvenanceList {
188 self.0.provenance_list(node)
189 }
190 fn allow_shell(&self) -> bool {
191 self.0.allow_shell()
192 }
193}
194
195impl Doc {
196 fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
200 let a = std::rc::Rc::new(a);
201 let r = a.clone();
202 Doc::Boxed(
203 Dyn(Box::new(quarb_mount::Shared(a))),
204 Box::new(move |n| r.locator(n)),
205 )
206 }
207
208 pub fn parse(input: &str, format: &str) -> Result<Doc> {
212 match format {
213 "kaiv" => {
218 let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
219 .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
220 return Ok(Self::boxed_kaiv(a));
221 }
222 "daiv" => {
223 let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
224 .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
225 return Ok(Self::boxed_kaiv(a));
226 }
227 "json" => quarb_json::JsonAdapter::parse(input)
228 .map(Doc::Json)
229 .context("parsing JSON"),
230 "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
231 .map(Doc::Json)
232 .context("parsing JSONL"),
233 "yaml" | "yml" => quarb_yaml::parse(input)
234 .map(Doc::Json)
235 .context("parsing YAML"),
236 "toml" => quarb_toml::parse(input)
237 .map(Doc::Json)
238 .context("parsing TOML"),
239 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
240 .map(Doc::Csv)
241 .context("parsing CSV"),
242 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
243 .map(Doc::Csv)
244 .context("parsing TSV"),
245 "xml" => quarb_xml::XmlAdapter::parse(input)
246 .map(Doc::Xml)
247 .context("parsing XML"),
248 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
249 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
250 "tex" | "latex" => Ok(Doc::Text(quarb_text_latex::parse(input))),
253 "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
257 "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
258 "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
259 "conllu" => quarb_text::TextModel::parse_conllu_text(input)
262 .map(Doc::Text)
263 .map_err(|e| anyhow::anyhow!("reading CoNLL-U: {e}")),
264 "corpus-conllu" => quarb_text::TextModel::parse_conllu_corpus(input)
265 .map(Doc::Text)
266 .map_err(|e| anyhow::anyhow!("reading CoNLL-U: {e}")),
267 "corpus-html" | "corpus-markdown" | "corpus" => {
269 let mut model = match format {
270 "corpus-html" => quarb_text_html::parse(input),
271 "corpus-markdown" => quarb_text_markdown::parse(input),
272 _ => quarb_text::TextModel::parse_plain(input),
273 };
274 model.tokenize();
275 Ok(Doc::Text(model))
276 }
277 #[cfg(feature = "koine")]
280 "text-bibtex" | "bibtex" => quarb_text_koine::parse_bibtex(input)
281 .map(Doc::Text)
282 .map_err(|e| anyhow::anyhow!("{e}")),
283 #[cfg(feature = "native")]
286 "code-rust" => quarb_code::CodeModel::parse(input, "rs")
287 .map(Doc::Code)
288 .context("parsing Rust at the code level"),
289 #[cfg(feature = "native")]
290 "code-python" => quarb_code::CodeModel::parse(input, "py")
291 .map(Doc::Code)
292 .context("parsing Python at the code level"),
293 #[cfg(feature = "native")]
294 "code-javascript" => quarb_code::CodeModel::parse(input, "js")
295 .map(Doc::Code)
296 .context("parsing JavaScript at the code level"),
297 #[cfg(feature = "native")]
298 "code-c" => quarb_code::CodeModel::parse(input, "c")
299 .map(Doc::Code)
300 .context("parsing C at the code level"),
301 other => bail!("unknown format: {other}"),
302 }
303 }
304
305 pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
314 match self {
315 Doc::Json(a) => a,
316 Doc::Csv(a) => a,
317 Doc::Xml(a) => a,
318 Doc::Html(a) => a,
319 Doc::Text(a) => a,
320 #[cfg(feature = "sqlite")]
321 Doc::Sqlite(a) => a,
322 #[cfg(feature = "native")]
323 Doc::Fs(a) => a,
324 #[cfg(feature = "native")]
325 Doc::FsDeep(a) => a,
326 #[cfg(feature = "native")]
327 Doc::Git(a) => a,
328 #[cfg(feature = "native")]
329 Doc::Archive(a) => a,
330 #[cfg(feature = "native")]
331 Doc::ArchiveRaw(a) => a,
332 #[cfg(feature = "native")]
333 Doc::Xlsx(a) => a,
334 #[cfg(feature = "native")]
335 Doc::Syntax(a) => a,
336 #[cfg(feature = "native")]
337 Doc::Code(a) => a,
338 Doc::Mount(a) => a,
339 Doc::Boxed(a, _) => &*a.0,
340 }
341 }
342
343 pub fn export(
349 &self,
350 query: &str,
351 now: (i64, u32),
352 allow_shell: bool,
353 kind: &str,
354 ) -> Result<String> {
355 let render = quarb_text::Render::from_name(kind)
356 .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
357 if query.trim().is_empty() {
359 let base = self.base_dyn();
360 return Ok(quarb_text::render_nodes(base, &[base.root()], render));
361 }
362 match self
363 .run(query, now, allow_shell)
364 .map_err(|e| anyhow::anyhow!("{e}"))?
365 {
366 QueryResult::Nodes(nodes) => {
367 Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
368 }
369 QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
370 }
371 }
372
373 pub fn run_modeled(
378 &self,
379 query: &str,
380 now: (i64, u32),
381 allow_shell: bool,
382 model: &quarb_model::Model,
383 ) -> quarb::Result<QueryResult> {
384 let (secs, nanos) = now;
385 let base = quarb_model::Borrowed(self.base_dyn());
386 let nowed = WithNow {
387 inner: &base,
388 secs,
389 nanos,
390 };
391 let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
392 if allow_shell {
393 quarb::run(query, &AllowShell { inner: &enriched })
394 } else {
395 quarb::run(query, &enriched)
396 }
397 }
398
399 pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
402 let enriched =
403 quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
404 enriched.locator(node, |bn| self.render(bn))
405 }
406
407 pub fn run(
408 &self,
409 query: &str,
410 now: (i64, u32),
411 allow_shell: bool,
412 ) -> quarb::Result<QueryResult> {
413 let (secs, nanos) = now;
414 macro_rules! go {
415 ($a:expr) => {{
416 let nowed = WithNow {
417 inner: $a,
418 secs,
419 nanos,
420 };
421 if allow_shell {
422 quarb::run(query, &AllowShell { inner: &nowed })
423 } else {
424 quarb::run(query, &nowed)
425 }
426 }};
427 }
428 match self {
429 Doc::Json(a) => go!(a),
430 Doc::Csv(a) => go!(a),
431 Doc::Xml(a) => go!(a),
432 Doc::Html(a) => go!(a),
433 Doc::Text(a) => go!(a),
434 #[cfg(feature = "sqlite")]
435 Doc::Sqlite(a) => go!(a),
436 #[cfg(feature = "native")]
437 Doc::Fs(a) => go!(a),
438 #[cfg(feature = "native")]
439 Doc::FsDeep(a) => go!(a),
440 #[cfg(feature = "native")]
441 Doc::Git(a) => go!(a),
442 #[cfg(feature = "native")]
443 Doc::Archive(a) => go!(a),
444 #[cfg(feature = "native")]
445 Doc::ArchiveRaw(a) => go!(a),
446 #[cfg(feature = "native")]
447 Doc::Xlsx(a) => go!(a),
448 #[cfg(feature = "native")]
449 Doc::Syntax(a) => go!(a),
450 #[cfg(feature = "native")]
451 Doc::Code(a) => go!(a),
452 Doc::Mount(a) => go!(a),
453 Doc::Boxed(a, _) => go!(a),
454 }
455 }
456
457 pub fn family(&self) -> &'static str {
461 match self {
462 Doc::Json(_) => "json",
463 Doc::Csv(_) => "csv",
464 Doc::Xml(_) => "xml",
465 Doc::Html(_) => "html",
466 Doc::Text(_) => "text",
467 #[cfg(feature = "sqlite")]
468 Doc::Sqlite(_) => "sqlite",
469 #[cfg(feature = "native")]
470 Doc::Fs(_) | Doc::FsDeep(_) => "fs",
471 #[cfg(feature = "native")]
472 Doc::Git(_) => "git",
473 #[cfg(feature = "native")]
474 Doc::Archive(_) | Doc::ArchiveRaw(_) => "archive",
475 #[cfg(feature = "native")]
476 Doc::Xlsx(_) => "xlsx",
477 #[cfg(feature = "native")]
478 Doc::Syntax(_) => "tree-sitter",
479 #[cfg(feature = "native")]
480 Doc::Code(_) => "code",
481 Doc::Mount(_) => "mount",
482 Doc::Boxed(..) => "boxed",
483 }
484 }
485
486 pub fn trace(
491 &self,
492 committed: &str,
493 staged: Option<&str>,
494 opts: &quarb::trace::Options,
495 ) -> quarb::Result<quarb_trace::Payload> {
496 let base = quarb_model::Borrowed(self.base_dyn());
497 quarb::trace::trace(committed, staged, &base, &|n| self.render(n), opts)
498 }
499
500 pub fn render(&self, node: NodeId) -> String {
501 match self {
502 Doc::Json(a) => a.pointer(node),
503 Doc::Csv(a) => a.locator(node),
504 Doc::Xml(a) => a.locator(node),
505 Doc::Html(a) => a.locator(node),
506 Doc::Text(a) => a.locator(node),
507 #[cfg(feature = "sqlite")]
508 Doc::Sqlite(a) => a.locator(node),
509 #[cfg(feature = "native")]
510 Doc::Fs(a) => a.path(node).display().to_string(),
511 #[cfg(feature = "native")]
512 Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
513 #[cfg(feature = "native")]
514 Doc::Git(a) => a.locator(node),
515 #[cfg(feature = "native")]
516 Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
517 #[cfg(feature = "native")]
518 Doc::ArchiveRaw(a) => a.locator(node),
519 #[cfg(feature = "native")]
520 Doc::Xlsx(a) => a.locator(node),
521 #[cfg(feature = "native")]
522 Doc::Syntax(a) => a.locator(node),
523 #[cfg(feature = "native")]
524 Doc::Code(a) => a.locator(node),
525 Doc::Mount(a) => generic_locator(a, node),
526 Doc::Boxed(_, render) => render(node),
527 }
528 }
529
530 #[cfg(feature = "sqlite")]
533 pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
534 Ok(Doc::Sqlite(
535 quarb_sqlite::SqliteAdapter::from_bytes(bytes)
536 .map_err(|e| anyhow::anyhow!("{e}"))
537 .context("opening SQLite bytes")?,
538 ))
539 }
540
541 #[cfg(not(feature = "sqlite"))]
545 pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
546 anyhow::bail!("this quarb-session was built without the `sqlite` feature")
547 }
548
549 pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
553 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
554 for (name, doc) in parts {
555 if mounts.iter().any(|m| m.name == name) {
556 bail!("two sources mount as '{name}'; give each a distinct name");
557 }
558 mounts.push(quarb_mount::Mount {
559 name,
560 target: None,
563 adapter: doc.into_boxed()?,
564 });
565 }
566 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
567 }
568
569 pub fn attach_url(&mut self, url: &str) {
574 match self {
575 Doc::Html(a) => a.set_document_url(url),
576 Doc::Text(a) => a.set_document_url(url),
577 _ => {}
578 }
579 }
580
581 pub fn url_roots(
587 &self,
588 urls: &[(String, String)],
589 ) -> std::collections::HashMap<String, quarb::NodeId> {
590 use quarb::AstAdapter;
591 let a = self.base_dyn();
592 let root = a.root();
593 match self {
594 Doc::Mount(_) => urls
595 .iter()
596 .filter_map(|(name, url)| {
597 a.children_named(root, name)
598 .first()
599 .map(|&n| (url.clone(), n))
600 })
601 .collect(),
602 _ => urls
603 .iter()
604 .take(1)
605 .map(|(_, url)| (url.clone(), root))
606 .collect(),
607 }
608 }
609
610 pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
615 let mut docs: Vec<(String, Doc)> = Vec::new();
616 for (name, format, text) in parts {
617 let doc = Doc::parse(text, format)
618 .with_context(|| format!("parsing '{name}' as {format}"))?;
619 docs.push((name.clone(), doc));
620 }
621 Doc::mount_docs(docs)
622 }
623
624 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
626 use quarb_mount::Shared;
627 Ok(match self {
628 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
629 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
630 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
631 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
632 Doc::Text(a) => Box::new(Shared(Rc::new(a))),
633 #[cfg(feature = "sqlite")]
634 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
635 #[cfg(feature = "native")]
636 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
637 #[cfg(feature = "native")]
638 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
639 #[cfg(feature = "native")]
640 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
641 #[cfg(feature = "native")]
642 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
643 #[cfg(feature = "native")]
644 Doc::ArchiveRaw(a) => Box::new(Shared(Rc::new(a))),
645 #[cfg(feature = "native")]
646 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
647 #[cfg(feature = "native")]
648 Doc::Syntax(a) => Box::new(Shared(Rc::new(a))),
649 #[cfg(feature = "native")]
650 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
651 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
652 Doc::Boxed(a, _) => a.0,
653 })
654 }
655}
656
657#[cfg(feature = "native")]
662impl Doc {
663 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
669 if path.is_dir() {
670 let fsopts = quarb_fs::FsOptions {
671 hidden: opts.hidden,
672 respect_ignore: opts.respect_ignore,
673 };
674 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
675 .with_context(|| format!("opening directory {}", path.display()))?;
676 return Ok(if opts.graft {
677 Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
678 fs,
679 |fs, n| Some(fs.path(n)),
680 ))
681 } else {
682 Doc::Fs(fs)
683 });
684 }
685
686 let s = path.to_string_lossy();
687 if let Some(repo) = s.strip_prefix("git:") {
688 let a =
689 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
690 return Ok(Doc::Git(a));
691 }
692 if let Some(rest) = s.strip_prefix("koine:")
697 && !rest.is_empty()
698 {
699 let (path_part, format) = match rest.split_once('?') {
701 Some((p, q)) => {
702 let mut fmt = None;
703 for pair in q.split('&') {
704 match pair.split_once('=') {
705 Some(("format", v)) => fmt = Some(v.to_string()),
706 _ => {
707 return Err(anyhow::anyhow!(
708 "unknown koine option {pair:?} — supported: format="
709 ));
710 }
711 }
712 }
713 (p, fmt)
714 }
715 None => (rest, None),
716 };
717 let target = Path::new(path_part);
718 let ext = target
719 .extension()
720 .and_then(|e| e.to_str())
721 .map(|e| e.to_ascii_lowercase());
722 let read = || -> Result<String> {
723 let text = std::fs::read_to_string(target)
724 .with_context(|| format!("reading {}", target.display()))?;
725 Ok(text
726 .strip_prefix('\u{feff}')
727 .map(str::to_owned)
728 .unwrap_or(text))
729 };
730 if let Some(fmt) = format {
731 let imported = match fmt.as_str() {
732 "atd" | "atk" => {
733 let model = quarb_text_koine::parse_file(target).with_context(|| {
734 format!("reading {} as an atrep document", target.display())
735 })?;
736 return Ok(Doc::Text(model));
737 }
738 "md" | "markdown" => quarb_text_koine::parse_markdown(&read()?),
739 "html" => quarb_text_koine::parse_html(&read()?),
740 "rst" => quarb_text_koine::parse_rst(&read()?),
741 "org" => quarb_text_koine::parse_org(&read()?),
742 "dj" | "djot" => quarb_text_koine::parse_djot(&read()?),
743 "tei" | "docbook" | "jats" | "usx" | "osis" | "rnc" | "opencorpora"
744 | "proiel" => quarb_text_koine::parse_xml_as(&read()?, &fmt),
745 other => {
746 return Err(anyhow::anyhow!(
747 "unknown koine format {other:?} — known: md, html, rst, org, djot, tei, docbook, jats, usx, osis, atd"
748 ));
749 }
750 };
751 return imported.map(Doc::Text).with_context(|| {
752 format!("importing {} through atrep as {fmt}", target.display())
753 });
754 }
755 let imported = match ext.as_deref() {
756 Some("atd" | "atk") => {
757 let model = quarb_text_koine::parse_file(target).with_context(|| {
758 format!("reading {} as an atrep document", target.display())
759 })?;
760 return Ok(Doc::Text(model));
761 }
762 Some("usfm" | "sfm") => quarb_text_koine::parse_xml_as(&read()?, "usfm"),
763 Some("bib") => quarb_text_koine::parse_bibtex(&read()?),
764 Some("md" | "markdown") => quarb_text_koine::parse_markdown(&read()?),
765 Some("html" | "htm") => quarb_text_koine::parse_html(&read()?),
766 Some("rst") => quarb_text_koine::parse_rst(&read()?),
767 Some("org") => quarb_text_koine::parse_org(&read()?),
768 Some("dj" | "djot") => quarb_text_koine::parse_djot(&read()?),
769 Some("xml") => {
770 let text = read()?;
771 match quarb_text_koine::detect_xml_kind(&text) {
772 Some(kind) => quarb_text_koine::parse_xml_as(&text, kind),
773 None => {
774 return Err(anyhow::anyhow!(
775 "{} declares no XML identity this route knows — force one with koine:{}?format=tei|docbook|jats|usx|osis|rnc|opencorpora|proiel",
776 target.display(),
777 target.display()
778 ));
779 }
780 }
781 }
782 Some("pdf") => {
783 return Err(anyhow::anyhow!(
784 "atrep cannot import a PDF — the print reading is text:{}",
785 target.display()
786 ));
787 }
788 _ => {
789 return Err(anyhow::anyhow!(
790 "no atrep import for this format yet — the native reading is text:{}",
791 target.display()
792 ));
793 }
794 };
795 return imported
796 .map(Doc::Text)
797 .with_context(|| format!("importing {} through atrep", target.display()));
798 }
799 if let Some(rest) = s.strip_prefix("lit:")
803 && !rest.is_empty()
804 {
805 let (rest, conllu, annotate, desm, format, cast, places, modernize) = match rest
806 .split_once('?')
807 {
808 Some((p, q)) => {
809 let (
810 mut conllu,
811 mut annotate,
812 mut desm,
813 mut format,
814 mut cast,
815 mut places,
816 mut modernize,
817 ) = (None, None, Vec::new(), None, None, None, None);
818 for pair in q.split('&') {
819 match pair.split_once('=') {
820 Some(("conllu", v)) => conllu = Some(v.to_string()),
821 Some(("annotate", v)) => annotate = Some(v.to_string()),
822 Some(("desm", v)) => desm.push(v.to_string()),
823 Some(("cast", v)) => cast = Some(v.to_string()),
824 Some(("places", v)) => places = Some(v.to_string()),
825 Some(("modernize", v)) => modernize = Some(v.to_string()),
826 Some(("format", v)) => format = Some(v.to_string()),
827 _ => bail!(
828 "unknown lit option {pair:?} — supported: conllu=, annotate=, desm=, cast=, places=, modernize=, format="
829 ),
830 }
831 }
832 (p, conllu, annotate, desm, format, cast, places, modernize)
833 }
834 None => (rest, None, None, Vec::new(), None, None, None, None),
835 };
836 let target = Path::new(rest);
837 let ext = target
838 .extension()
839 .and_then(|e| e.to_str())
840 .map(|e| e.to_ascii_lowercase());
841 let read = || -> Result<String> {
842 let text = std::fs::read_to_string(target)
843 .with_context(|| format!("reading {}", target.display()))?;
844 Ok(text
845 .strip_prefix('\u{feff}')
846 .map(str::to_owned)
847 .unwrap_or(text))
848 };
849 let kind = match format {
850 Some(k) => k,
851 None => match ext.as_deref() {
852 Some("atd" | "atk") => "atd".to_string(),
853 Some("usx") => "usx".to_string(),
854 _ => match quarb_text_koine::detect_xml_kind(&read()?) {
855 Some(k) => k.to_string(),
856 None => bail!(
857 "lit: reads TEI, USX, OSIS, RNC, OpenCorpora, PROIEL and litogramma — {rest} declares none of them (koine:{rest} reads the other XML vocabularies, corpus:{rest} plain prose)"
858 ),
859 },
860 },
861 };
862 let mut model = match kind.as_str() {
863 "atd" | "atk" => quarb_text_koine::parse_lit_file(target).with_context(|| {
864 format!("reading {} as an atrep document", target.display())
865 })?,
866 "tei" | "usx" | "osis" | "rnc" | "opencorpora" | "proiel" => {
867 quarb_text_koine::parse_lit_xml_as(&read()?, &kind).with_context(|| {
868 format!("reading {} as {}", target.display(), kind.to_uppercase())
869 })?
870 }
871 other => bail!(
872 "lit: reads TEI, USX, OSIS, RNC, OpenCorpora, PROIEL and litogramma — not {other} (koine:{rest} reads the other XML vocabularies)"
873 ),
874 };
875 model.set_document_path(rest);
876 return finish_corpus(
877 model, conllu, annotate, &desm, cast, places, modernize, opts, "lit",
878 );
879 }
880 if let Some(rest) = s.strip_prefix("corpus:")
887 && !rest.is_empty()
888 {
889 let (rest, conllu, annotate, desm, format, cast, places, modernize) = match rest
891 .split_once('?')
892 {
893 Some((p, q)) => {
894 let (
895 mut conllu,
896 mut annotate,
897 mut desm,
898 mut format,
899 mut cast,
900 mut places,
901 mut modernize,
902 ) = (None, None, Vec::new(), None, None, None, None);
903 for pair in q.split('&') {
904 match pair.split_once('=') {
905 Some(("conllu", v)) => conllu = Some(v.to_string()),
906 Some(("annotate", v)) => annotate = Some(v.to_string()),
907 Some(("desm", v)) => desm.push(v.to_string()),
908 Some(("cast", v)) => cast = Some(v.to_string()),
909 Some(("places", v)) => places = Some(v.to_string()),
910 Some(("modernize", v)) => modernize = Some(v.to_string()),
911 Some(("format", "conllu")) => format = Some("conllu"),
912 Some(("format", v)) => bail!(
913 "corpus: format={v} is not a treebank format — format= takes conllu"
914 ),
915 _ => bail!(
916 "unknown corpus option {pair:?} — supported: conllu=, annotate=, desm=, cast=, places=, modernize=, format="
917 ),
918 }
919 }
920 (p, conllu, annotate, desm, format, cast, places, modernize)
921 }
922 None => (rest, None, None, Vec::new(), None, None, None, None),
923 };
924 let target = Path::new(rest);
925 let ext = target
926 .extension()
927 .and_then(|e| e.to_str())
928 .map(|e| e.to_ascii_lowercase());
929 if ext.as_deref() == Some("pdf") {
930 bail!(
931 "corpus: needs a prose reading, and a PDF's is line geometry — text:{rest} reads its lines"
932 );
933 }
934 let own_annotation = || {
939 if conllu.is_some() || annotate.is_some() {
940 bail!(
941 "corpus: {rest} is its own annotation — conllu= and annotate= apply to prose"
942 );
943 }
944 Ok(())
945 };
946 if target.is_dir() {
947 own_annotation()?;
948 let files = quarb_text::read_conllu_dir(target).map_err(|e| {
949 anyhow::anyhow!("corpus: over a directory reads its treebank — {e}")
950 })?;
951 let refs: Vec<(&str, &str)> = files
952 .iter()
953 .map(|(n, t)| (n.as_str(), t.as_str()))
954 .collect();
955 let mut model = quarb_text::TextModel::parse_conllu_corpus_set(&refs)
956 .map_err(|e| anyhow::anyhow!("reading {rest} as CoNLL-U: {e}"))?;
957 model.set_document_path(rest);
958 return treebank_options(model, &cast, &places, &modernize);
959 }
960 let treebank = matches!(ext.as_deref(), Some("conllu" | "conllup"))
961 || format == Some("conllu")
962 || (ext.is_none()
963 && std::fs::read_to_string(target)
964 .is_ok_and(|t| quarb_text::looks_like_conllu(&t)));
965 if treebank {
966 own_annotation()?;
967 let text = std::fs::read_to_string(target)
968 .with_context(|| format!("reading {}", target.display()))?;
969 let text = text
970 .strip_prefix('\u{feff}')
971 .map(str::to_owned)
972 .unwrap_or(text);
973 let Doc::Text(mut model) = Doc::parse(&text, "corpus-conllu")? else {
974 bail!("corpus: needs a prose reading of {rest}")
975 };
976 model.set_document_path(rest);
977 return treebank_options(model, &cast, &places, &modernize);
978 }
979 let route = if matches!(ext.as_deref(), Some("xml" | "tei")) {
980 format!("koine:{rest}")
981 } else {
982 format!("text:{rest}")
983 };
984 let Doc::Text(model) = Doc::open(Path::new(&route), opts)? else {
985 bail!("corpus: needs a prose reading of {rest}")
986 };
987 return finish_corpus(
988 model, conllu, annotate, &desm, cast, places, modernize, opts, "corpus",
989 );
990 }
991 if let Some(rest) = s.strip_prefix("text:")
995 && !rest.is_empty()
996 {
997 let target = Path::new(rest);
998 if target.is_dir() {
1001 let files = quarb_text::read_conllu_dir(target).map_err(|e| {
1002 anyhow::anyhow!("text: over a directory reads its treebank — {e}")
1003 })?;
1004 let refs: Vec<(&str, &str)> = files
1005 .iter()
1006 .map(|(n, t)| (n.as_str(), t.as_str()))
1007 .collect();
1008 let mut model = quarb_text::TextModel::parse_conllu_text_set(&refs)
1009 .map_err(|e| anyhow::anyhow!("reading {rest} as CoNLL-U: {e}"))?;
1010 model.set_document_path(rest);
1011 return Ok(Doc::Text(model));
1012 }
1013 let ext = target
1016 .extension()
1017 .and_then(|e| e.to_str())
1018 .map(|e| e.to_ascii_lowercase());
1019 if let Some("usfm" | "sfm") = ext.as_deref() {
1021 let text = std::fs::read_to_string(target)
1022 .with_context(|| format!("reading {}", target.display()))?;
1023 let text = text
1024 .strip_prefix('\u{feff}')
1025 .map(str::to_owned)
1026 .unwrap_or(text);
1027 let model = quarb_text_koine::parse_xml_as(&text, "usfm")
1028 .with_context(|| format!("reading {} as USFM", target.display()))?;
1029 return Ok(Doc::Text(model));
1030 }
1031 if let Some("atd" | "atk") = ext.as_deref() {
1032 let model = quarb_text_koine::parse_file(target).with_context(|| {
1033 format!("reading {} as an atrep document", target.display())
1034 })?;
1035 return Ok(Doc::Text(model));
1036 }
1037 if ext.as_deref() == Some("pdf") {
1038 let bytes = std::fs::read(target)
1039 .with_context(|| format!("reading {}", target.display()))?;
1040 let model = quarb_text_pdf::parse(&bytes)
1041 .with_context(|| format!("reading {} as PDF", target.display()))?;
1042 let a = std::rc::Rc::new(model);
1043 let r = a.clone();
1044 return Ok(Doc::Boxed(
1045 Dyn(Box::new(quarb_mount::Shared(a))),
1046 Box::new(move |n| r.locator(n)),
1047 ));
1048 }
1049 if let Some(ext @ ("docx" | "epub")) = ext.as_deref() {
1050 let bytes = std::fs::read(target)
1051 .with_context(|| format!("reading {}", target.display()))?;
1052 let model = if ext == "docx" {
1053 quarb_text_docx::parse(&bytes)
1054 .with_context(|| format!("reading {} as Word", target.display()))?
1055 } else {
1056 quarb_text_epub::parse(&bytes)
1057 .with_context(|| format!("reading {} as EPUB", target.display()))?
1058 };
1059 return Ok(Doc::Text(model));
1060 }
1061 let text = std::fs::read_to_string(target)
1062 .with_context(|| format!("reading {}", target.display()))?;
1063 let text = text
1064 .strip_prefix('\u{feff}')
1065 .map(str::to_owned)
1066 .unwrap_or(text);
1067 let format = match target
1068 .extension()
1069 .and_then(|e| e.to_str())
1070 .map(|e| e.to_ascii_lowercase())
1071 .as_deref()
1072 {
1073 Some("html" | "htm") => "text-html",
1074 Some("md" | "markdown") => "text-markdown",
1075 Some("tex" | "latex") => "tex",
1076 Some("txt") => "text",
1077 Some("conllu" | "conllup") => "conllu",
1078 #[cfg(feature = "koine")]
1079 Some("bib") => "text-bibtex",
1080 _ if text.trim_start().starts_with('<') => "text-html",
1081 None if quarb_text::looks_like_conllu(&text) => "conllu",
1082 _ => "text",
1083 };
1084 return Doc::parse(&text, format);
1085 }
1086 if let Some(rest) = s.strip_prefix("code:")
1091 && !rest.is_empty()
1092 {
1093 if opts.no_graft {
1094 bail!(
1095 "no_graft refuses the code: prefix: the prefix's whole \
1096 meaning is the grafted code-level view"
1097 );
1098 }
1099 let target = Path::new(rest);
1100 if target.is_dir() {
1101 let fsopts = quarb_fs::FsOptions {
1102 hidden: opts.hidden,
1103 respect_ignore: opts.respect_ignore,
1104 };
1105 let fs = quarb_fs::FsAdapter::with_options(target, fsopts)
1106 .with_context(|| format!("opening directory {}", target.display()))?;
1107 return Ok(Doc::FsDeep(
1108 quarb_compose::ComposeAdapter::with_source_paths(fs, |fs, n| Some(fs.path(n)))
1109 .with_source_graft(quarb_compose::SourceGraft::Code),
1110 ));
1111 }
1112 let a = quarb_code::CodeModel::open(target)
1113 .with_context(|| format!("parsing {} at the code level", target.display()))?;
1114 return Ok(Doc::Code(a));
1115 }
1116
1117 let ext = path
1118 .extension()
1119 .and_then(|e| e.to_str())
1120 .map(|e| e.to_ascii_lowercase());
1121
1122 if let Some(e) = &ext
1123 && quarb_tree_sitter::supported(e)
1124 {
1125 let a =
1126 quarb_tree_sitter::TreeSitterAdapter::open(path).context("parsing source file")?;
1127 return Ok(Doc::Syntax(a));
1128 }
1129 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
1130 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
1131 return Ok(Doc::Xlsx(a));
1132 }
1133 if is_sqlite(path) {
1134 #[cfg(not(feature = "sqlite"))]
1138 anyhow::bail!(
1139 "{}: this quarb-session was built without the `sqlite` feature",
1140 path.display()
1141 );
1142 #[cfg(feature = "sqlite")]
1143 {
1144 let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
1145 .context("opening SQLite database")?;
1146 return Ok(Doc::Sqlite(a));
1147 }
1148 }
1149 if is_archive(path) {
1150 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
1151 return Ok(if opts.no_graft {
1152 Doc::ArchiveRaw(a)
1153 } else {
1154 Doc::Archive(quarb_compose::ComposeAdapter::new(a))
1155 });
1156 }
1157
1158 let text =
1160 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1161 let text = text
1162 .strip_prefix('\u{feff}')
1163 .map(str::to_owned)
1164 .unwrap_or(text);
1165 match ext.as_deref() {
1166 Some("csv") => Doc::parse(&text, "csv"),
1167 Some("tsv") => Doc::parse(&text, "tsv"),
1168 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
1169 Some("toml") => Doc::parse(&text, "toml"),
1170 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
1171 Some("txt") => Doc::parse(&text, "text"),
1172 Some("conllu" | "conllup") => Doc::parse(&text, "conllu"),
1173 Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
1174 _ => {
1175 if is_xml(path, &text) {
1176 Doc::parse(&text, "xml")
1177 } else if is_html(path, &text) {
1178 Doc::parse(&text, "html")
1179 } else {
1180 Doc::parse(&text, "json")
1181 }
1182 }
1183 }
1184 }
1185
1186 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
1190 let specs: Vec<crate::MountSpec> = paths
1191 .iter()
1192 .map(|p| crate::MountSpec {
1193 name: None,
1194 path: p.clone(),
1195 })
1196 .collect();
1197 Doc::mount_specs(&specs, opts)
1198 }
1199
1200 pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
1203 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
1204 for (i, spec) in specs.iter().enumerate() {
1205 let name = spec.name.clone().unwrap_or_else(|| {
1206 spec.path
1207 .file_stem()
1208 .map(|s| s.to_string_lossy().into_owned())
1209 .unwrap_or_else(|| format!("doc{i}"))
1210 });
1211 if mounts.iter().any(|m| m.name == name) {
1212 bail!(
1213 "input '{}' mounts as '{name}', colliding with an earlier input of the \
1214 same name; give each a distinct basename (or a NAME=TARGET alias)",
1215 spec.path.display()
1216 );
1217 }
1218 let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
1219 mounts.push(quarb_mount::Mount {
1220 name,
1221 target: Some(spec.path.display().to_string()),
1222 adapter,
1223 });
1224 }
1225 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
1226 }
1227}
1228
1229fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
1233 let mut parts = Vec::new();
1234 let mut cur = Some(node);
1235 while let Some(n) = cur {
1236 if let Some(nm) = a.name(n) {
1237 parts.push(nm);
1238 }
1239 cur = a.parent(n);
1240 }
1241 parts.reverse();
1242 format!("/{}", parts.join("/"))
1243}
1244
1245#[cfg(feature = "native")]
1248fn is_sqlite(path: &Path) -> bool {
1249 if path
1250 .extension()
1251 .and_then(|e| e.to_str())
1252 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
1253 {
1254 return true;
1255 }
1256 use std::io::Read as _;
1257 let mut buf = [0u8; 16];
1258 std::fs::File::open(path)
1259 .and_then(|mut f| f.read_exact(&mut buf))
1260 .is_ok()
1261 && &buf == b"SQLite format 3\0"
1262}
1263
1264#[cfg(feature = "native")]
1266fn is_archive(path: &Path) -> bool {
1267 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
1268 matches!(
1269 e.to_ascii_lowercase().as_str(),
1270 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
1271 )
1272 }) {
1273 return true;
1274 }
1275 use std::io::Read as _;
1276 let mut buf = [0u8; 2];
1277 std::fs::File::open(path)
1278 .and_then(|mut f| f.read_exact(&mut buf))
1279 .is_ok()
1280 && (&buf == b"PK" || buf == [0x1f, 0x8b])
1281}
1282
1283#[cfg(feature = "native")]
1286fn is_xml(path: &Path, text: &str) -> bool {
1287 path.extension()
1288 .and_then(|e| e.to_str())
1289 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
1290 || text.trim_start().starts_with("<?xml")
1291}
1292
1293#[cfg(feature = "native")]
1296fn is_html(path: &Path, text: &str) -> bool {
1297 path.extension()
1298 .and_then(|e| e.to_str())
1299 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
1300 || text.trim_start().starts_with('<')
1301}
1302
1303fn treebank_options(
1309 mut model: quarb_text::TextModel,
1310 cast: &Option<String>,
1311 places: &Option<String>,
1312 modernize: &Option<String>,
1313) -> Result<Doc> {
1314 if let Some(t) = modernize {
1315 model
1316 .set_orthography(t)
1317 .map_err(|e| anyhow::anyhow!("corpus: {e}"))?;
1318 }
1319 if let Some(f) = cast {
1320 let rows = quarb_text::CastRow::read_csv(f)
1321 .map_err(|e| anyhow::anyhow!("corpus: reading the cast table {f}: {e}"))?;
1322 model.apply_cast(&rows);
1323 }
1324 if let Some(f) = places {
1325 let rows = quarb_text::CastRow::read_csv(f)
1326 .map_err(|e| anyhow::anyhow!("corpus: reading the places table {f}: {e}"))?;
1327 model.apply_places(&rows);
1328 }
1329 Ok(Doc::Text(model))
1330}
1331
1332fn finish_corpus(
1333 mut model: quarb_text::TextModel,
1334 conllu: Option<String>,
1335 annotate: Option<String>,
1336 desm: &[String],
1337 cast: Option<String>,
1338 places: Option<String>,
1339 modernize: Option<String>,
1340 opts: &Options,
1341 level: &str,
1342) -> Result<Doc> {
1343 if let Some(table) = &modernize {
1344 model
1345 .set_orthography(table)
1346 .map_err(|e| anyhow::anyhow!("{level}: {e}"))?;
1347 }
1348 let conllu_text = match (conllu, annotate) {
1349 (Some(_), Some(_)) => bail!("{level}: takes conllu= or annotate=, not both"),
1350 (Some(file), None) => Some(
1351 std::fs::read_to_string(&file).with_context(|| format!("reading {file} as CoNLL-U"))?,
1352 ),
1353 (None, Some(cmd)) => {
1354 if !opts.allow_shell {
1355 bail!("{level}: annotate= runs a command; the shell gate is closed");
1356 }
1357 use std::io::Write;
1358 let mut child = std::process::Command::new("sh")
1359 .arg("-c")
1360 .arg(&cmd)
1361 .stdin(std::process::Stdio::piped())
1362 .stdout(std::process::Stdio::piped())
1363 .spawn()
1364 .with_context(|| format!("running annotator {cmd:?}"))?;
1365 if let Some(mut stdin) = child.stdin.take() {
1366 stdin
1367 .write_all(model.corpus_text().as_bytes())
1368 .context("feeding the annotator")?;
1369 }
1370 let out = child
1371 .wait_with_output()
1372 .context("waiting for the annotator")?;
1373 if !out.status.success() {
1374 bail!("annotator {cmd:?} failed: {}", out.status);
1375 }
1376 Some(String::from_utf8(out.stdout).context("annotator output is not UTF-8")?)
1377 }
1378 (None, None) => None,
1379 };
1380 match conllu_text {
1381 Some(text) => model
1382 .annotate_conllu(&text)
1383 .map_err(|e| anyhow::anyhow!("{level}: {e}"))?,
1384 None => {
1385 let mut bonds = opts.bonds.as_ref().map(|b| (**b).clone());
1386 for f in desm {
1387 let more = syndesmos::Syndesmos::load(f)
1388 .map_err(|e| anyhow::anyhow!("reading {f}: {e}"))?;
1389 bonds
1390 .get_or_insert_with(syndesmos::Syndesmos::empty)
1391 .extend(more);
1392 }
1393 model.tokenize_with(bonds.as_ref())
1394 }
1395 }
1396 if let Some(file) = cast {
1397 let rows = quarb_text::CastRow::read_csv(&file)
1398 .map_err(|e| anyhow::anyhow!("{level}: reading the cast table {file}: {e}"))?;
1399 model.apply_cast(&rows);
1400 }
1401 if let Some(file) = places {
1402 let rows = quarb_text::CastRow::read_csv(&file)
1403 .map_err(|e| anyhow::anyhow!("{level}: reading the places table {file}: {e}"))?;
1404 model.apply_places(&rows);
1405 }
1406 Ok(Doc::Text(model))
1407}