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 descend: bool,
30 pub refs: Rc<Vec<(String, String)>>,
33}
34
35pub enum Doc {
39 Json(quarb_json::JsonAdapter),
40 Csv(quarb_csv::CsvAdapter),
41 Xml(quarb_xml::XmlAdapter),
42 Html(quarb_html::HtmlAdapter),
43 Text(quarb_text::TextModel),
44 #[cfg(feature = "sqlite")]
45 Sqlite(quarb_sqlite::SqliteAdapter),
46 #[cfg(feature = "native")]
47 Fs(quarb_fs::FsAdapter),
48 #[cfg(feature = "native")]
49 FsDeep(quarb_compose::ComposeAdapter<quarb_fs::FsAdapter>),
50 #[cfg(feature = "native")]
51 Git(quarb_git::GitAdapter),
52 #[cfg(feature = "native")]
53 Archive(quarb_compose::ComposeAdapter<quarb_archive::ArchiveAdapter>),
54 #[cfg(feature = "native")]
55 Xlsx(quarb_xlsx::XlsxAdapter),
56 #[cfg(feature = "native")]
57 Syntax(quarb_tree_sitter::TreeSitterAdapter),
58 #[cfg(feature = "native")]
59 Code(quarb_code::CodeModel),
60 Mount(quarb_mount::MountAdapter),
61 Boxed(Dyn, Box<dyn Fn(NodeId) -> String>),
65}
66
67pub struct Dyn(pub Box<dyn quarb::AstAdapter>);
70
71impl quarb::AstAdapter for Dyn {
72 fn root(&self) -> NodeId {
73 self.0.root()
74 }
75 fn children(&self, node: NodeId) -> Vec<NodeId> {
76 self.0.children(node)
77 }
78 fn name(&self, node: NodeId) -> Option<String> {
79 self.0.name(node)
80 }
81 fn parent(&self, node: NodeId) -> Option<NodeId> {
82 self.0.parent(node)
83 }
84 fn traits(&self, node: NodeId) -> Vec<String> {
85 self.0.traits(node)
86 }
87 fn property(&self, node: NodeId, name: &str) -> Option<quarb::Value> {
88 self.0.property(node, name)
89 }
90 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
91 self.0.children_named(node, name)
92 }
93 fn default_value(&self, node: NodeId) -> Option<quarb::Value> {
94 self.0.default_value(node)
95 }
96 fn metadata(&self, node: NodeId, key: &str) -> Option<quarb::Value> {
97 self.0.metadata(node, key)
98 }
99 fn aliased_metadata(&self, node: NodeId) -> &'static [&'static str] {
100 self.0.aliased_metadata(node)
101 }
102 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
103 self.0.links(node)
104 }
105 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
106 self.0.backlinks(node)
107 }
108 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
109 self.0.resolve(node, property, hint)
110 }
111 fn link_property(
112 &self,
113 source: NodeId,
114 label: &str,
115 target: NodeId,
116 name: &str,
117 ) -> Option<quarb::Value> {
118 self.0.link_property(source, label, target, name)
119 }
120 fn quantifier_bound(&self) -> usize {
121 self.0.quantifier_bound()
122 }
123 fn invocation_instant(&self) -> Option<(i64, u32)> {
124 self.0.invocation_instant()
125 }
126 fn unit_scale(&self, expr: &str) -> Option<(f64, String)> {
127 self.0.unit_scale(expr)
128 }
129}
130
131impl Doc {
132 fn boxed_kaiv(a: quarb_kaiv::KaivAdapter) -> Doc {
136 let a = std::rc::Rc::new(a);
137 let r = a.clone();
138 Doc::Boxed(
139 Dyn(Box::new(quarb_mount::Shared(a))),
140 Box::new(move |n| r.locator(n)),
141 )
142 }
143
144 pub fn parse(input: &str, format: &str) -> Result<Doc> {
148 match format {
149 "kaiv" => {
154 let a = quarb_kaiv::KaivAdapter::parse_kaiv(input)
155 .map_err(|e| anyhow::anyhow!("parsing kaiv: {e}"))?;
156 return Ok(Self::boxed_kaiv(a));
157 }
158 "daiv" => {
159 let a = quarb_kaiv::KaivAdapter::parse_daiv(input)
160 .map_err(|e| anyhow::anyhow!("parsing daiv: {e}"))?;
161 return Ok(Self::boxed_kaiv(a));
162 }
163 "json" => quarb_json::JsonAdapter::parse(input)
164 .map(Doc::Json)
165 .context("parsing JSON"),
166 "jsonl" | "ndjson" => quarb_json::JsonAdapter::parse_lines(input)
167 .map(Doc::Json)
168 .context("parsing JSONL"),
169 "yaml" | "yml" => quarb_yaml::parse(input).map(Doc::Json).context("parsing YAML"),
170 "toml" => quarb_toml::parse(input).map(Doc::Json).context("parsing TOML"),
171 "csv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b',')
172 .map(Doc::Csv)
173 .context("parsing CSV"),
174 "tsv" => quarb_csv::CsvAdapter::parse_with_delimiter(input, b'\t')
175 .map(Doc::Csv)
176 .context("parsing TSV"),
177 "xml" => quarb_xml::XmlAdapter::parse(input)
178 .map(Doc::Xml)
179 .context("parsing XML"),
180 "html" => Ok(Doc::Html(quarb_html::HtmlAdapter::parse(input))),
181 "markdown" | "md" => Ok(Doc::Html(quarb_markdown::parse(input))),
182 "text-html" => Ok(Doc::Text(quarb_text_html::parse(input))),
186 "text-markdown" | "text-md" => Ok(Doc::Text(quarb_text_markdown::parse(input))),
187 "text" => Ok(Doc::Text(quarb_text::TextModel::parse_plain(input))),
188 #[cfg(feature = "native")]
191 "code-rust" => quarb_code::CodeModel::parse(input, "rs")
192 .map(Doc::Code)
193 .context("parsing Rust at the code level"),
194 #[cfg(feature = "native")]
195 "code-python" => quarb_code::CodeModel::parse(input, "py")
196 .map(Doc::Code)
197 .context("parsing Python at the code level"),
198 #[cfg(feature = "native")]
199 "code-javascript" => quarb_code::CodeModel::parse(input, "js")
200 .map(Doc::Code)
201 .context("parsing JavaScript at the code level"),
202 #[cfg(feature = "native")]
203 "code-c" => quarb_code::CodeModel::parse(input, "c")
204 .map(Doc::Code)
205 .context("parsing C at the code level"),
206 other => bail!("unknown format: {other}"),
207 }
208 }
209
210 pub(crate) fn base_dyn(&self) -> &dyn quarb::AstAdapter {
219 match self {
220 Doc::Json(a) => a,
221 Doc::Csv(a) => a,
222 Doc::Xml(a) => a,
223 Doc::Html(a) => a,
224 Doc::Text(a) => a,
225 #[cfg(feature = "sqlite")]
226 Doc::Sqlite(a) => a,
227 #[cfg(feature = "native")]
228 Doc::Fs(a) => a,
229 #[cfg(feature = "native")]
230 Doc::FsDeep(a) => a,
231 #[cfg(feature = "native")]
232 Doc::Git(a) => a,
233 #[cfg(feature = "native")]
234 Doc::Archive(a) => a,
235 #[cfg(feature = "native")]
236 Doc::Xlsx(a) => a,
237 #[cfg(feature = "native")]
238 Doc::Syntax(a) => a,
239 #[cfg(feature = "native")]
240 Doc::Code(a) => a,
241 Doc::Mount(a) => a,
242 Doc::Boxed(a, _) => &*a.0,
243 }
244 }
245
246 pub fn export(
252 &self,
253 query: &str,
254 now: (i64, u32),
255 allow_shell: bool,
256 kind: &str,
257 ) -> Result<String> {
258 let render = quarb_text::Render::from_name(kind)
259 .ok_or_else(|| anyhow::anyhow!("unknown export format: {kind} (md, html, txt)"))?;
260 if query.trim().is_empty() {
262 let base = self.base_dyn();
263 return Ok(quarb_text::render_nodes(base, &[base.root()], render));
264 }
265 match self
266 .run(query, now, allow_shell)
267 .map_err(|e| anyhow::anyhow!("{e}"))?
268 {
269 QueryResult::Nodes(nodes) => {
270 Ok(quarb_text::render_nodes(self.base_dyn(), &nodes, render))
271 }
272 QueryResult::Values(values) => Ok(quarb_text::render::render_values(&values, render)),
273 }
274 }
275
276 pub fn run_modeled(
281 &self,
282 query: &str,
283 now: (i64, u32),
284 allow_shell: bool,
285 model: &quarb_model::Model,
286 ) -> quarb::Result<QueryResult> {
287 let (secs, nanos) = now;
288 let base = quarb_model::Borrowed(self.base_dyn());
289 let nowed = WithNow {
290 inner: &base,
291 secs,
292 nanos,
293 };
294 let enriched = quarb_model::ModelAdapter::new(nowed, model.clone());
295 if allow_shell {
296 quarb::run(query, &AllowShell { inner: &enriched })
297 } else {
298 quarb::run(query, &enriched)
299 }
300 }
301
302 pub fn render_modeled(&self, node: NodeId, model: &quarb_model::Model) -> String {
305 let enriched =
306 quarb_model::ModelAdapter::new(quarb_model::Borrowed(self.base_dyn()), model.clone());
307 enriched.locator(node, |bn| self.render(bn))
308 }
309
310 pub fn run(&self, query: &str, now: (i64, u32), allow_shell: bool) -> quarb::Result<QueryResult> {
311 let (secs, nanos) = now;
312 macro_rules! go {
313 ($a:expr) => {{
314 let nowed = WithNow {
315 inner: $a,
316 secs,
317 nanos,
318 };
319 if allow_shell {
320 quarb::run(query, &AllowShell { inner: &nowed })
321 } else {
322 quarb::run(query, &nowed)
323 }
324 }};
325 }
326 match self {
327 Doc::Json(a) => go!(a),
328 Doc::Csv(a) => go!(a),
329 Doc::Xml(a) => go!(a),
330 Doc::Html(a) => go!(a),
331 Doc::Text(a) => go!(a),
332 #[cfg(feature = "sqlite")]
333 Doc::Sqlite(a) => go!(a),
334 #[cfg(feature = "native")]
335 Doc::Fs(a) => go!(a),
336 #[cfg(feature = "native")]
337 Doc::FsDeep(a) => go!(a),
338 #[cfg(feature = "native")]
339 Doc::Git(a) => go!(a),
340 #[cfg(feature = "native")]
341 Doc::Archive(a) => go!(a),
342 #[cfg(feature = "native")]
343 Doc::Xlsx(a) => go!(a),
344 #[cfg(feature = "native")]
345 Doc::Syntax(a) => go!(a),
346 #[cfg(feature = "native")]
347 Doc::Code(a) => go!(a),
348 Doc::Mount(a) => go!(a),
349 Doc::Boxed(a, _) => go!(a),
350 }
351 }
352
353 pub fn render(&self, node: NodeId) -> String {
355 match self {
356 Doc::Json(a) => a.pointer(node),
357 Doc::Csv(a) => a.locator(node),
358 Doc::Xml(a) => a.locator(node),
359 Doc::Html(a) => a.locator(node),
360 Doc::Text(a) => a.locator(node),
361 #[cfg(feature = "sqlite")]
362 Doc::Sqlite(a) => a.locator(node),
363 #[cfg(feature = "native")]
364 Doc::Fs(a) => a.path(node).display().to_string(),
365 #[cfg(feature = "native")]
366 Doc::FsDeep(a) => a.locator(node, |o| a.outer().path(o).display().to_string()),
367 #[cfg(feature = "native")]
368 Doc::Git(a) => a.locator(node),
369 #[cfg(feature = "native")]
370 Doc::Archive(a) => a.locator(node, |o| a.outer().locator(o)),
371 #[cfg(feature = "native")]
372 Doc::Xlsx(a) => a.locator(node),
373 #[cfg(feature = "native")]
374 Doc::Syntax(a) => a.locator(node),
375 #[cfg(feature = "native")]
376 Doc::Code(a) => a.locator(node),
377 Doc::Mount(a) => generic_locator(a, node),
378 Doc::Boxed(_, render) => render(node),
379 }
380 }
381
382 #[cfg(feature = "sqlite")]
385 pub fn sqlite_bytes(bytes: &[u8]) -> Result<Doc> {
386 Ok(Doc::Sqlite(
387 quarb_sqlite::SqliteAdapter::from_bytes(bytes)
388 .map_err(|e| anyhow::anyhow!("{e}"))
389 .context("opening SQLite bytes")?,
390 ))
391 }
392
393 #[cfg(not(feature = "sqlite"))]
397 pub fn sqlite_bytes(_bytes: &[u8]) -> Result<Doc> {
398 anyhow::bail!(
399 "this quarb-session was built without the `sqlite` feature"
400 )
401 }
402
403 pub fn mount_docs(parts: Vec<(String, Doc)>) -> Result<Doc> {
407 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
408 for (name, doc) in parts {
409 if mounts.iter().any(|m| m.name == name) {
410 bail!("two sources mount as '{name}'; give each a distinct name");
411 }
412 mounts.push(quarb_mount::Mount {
413 name,
414 target: None,
417 adapter: doc.into_boxed()?,
418 });
419 }
420 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
421 }
422
423 pub fn mount_texts(parts: &[(String, String, String)]) -> Result<Doc> {
428 let mut docs: Vec<(String, Doc)> = Vec::new();
429 for (name, format, text) in parts {
430 let doc = Doc::parse(text, format)
431 .with_context(|| format!("parsing '{name}' as {format}"))?;
432 docs.push((name.clone(), doc));
433 }
434 Doc::mount_docs(docs)
435 }
436
437 fn into_boxed(self) -> Result<Box<dyn quarb::AstAdapter>> {
439 use quarb_mount::Shared;
440 Ok(match self {
441 Doc::Json(a) => Box::new(Shared(Rc::new(a))),
442 Doc::Csv(a) => Box::new(Shared(Rc::new(a))),
443 Doc::Xml(a) => Box::new(Shared(Rc::new(a))),
444 Doc::Html(a) => Box::new(Shared(Rc::new(a))),
445 Doc::Text(a) => Box::new(Shared(Rc::new(a))),
446 #[cfg(feature = "sqlite")]
447 Doc::Sqlite(a) => Box::new(Shared(Rc::new(a))),
448 #[cfg(feature = "native")]
449 Doc::Fs(a) => Box::new(Shared(Rc::new(a))),
450 #[cfg(feature = "native")]
451 Doc::FsDeep(a) => Box::new(Shared(Rc::new(a))),
452 #[cfg(feature = "native")]
453 Doc::Git(a) => Box::new(Shared(Rc::new(a))),
454 #[cfg(feature = "native")]
455 Doc::Archive(a) => Box::new(Shared(Rc::new(a))),
456 #[cfg(feature = "native")]
457 Doc::Xlsx(a) => Box::new(Shared(Rc::new(a))),
458 #[cfg(feature = "native")]
459 Doc::Syntax(a) => Box::new(Shared(Rc::new(a))),
460 #[cfg(feature = "native")]
461 Doc::Code(a) => Box::new(Shared(Rc::new(a))),
462 Doc::Mount(_) => bail!("cannot nest a mount inside a mount"),
463 Doc::Boxed(a, _) => a.0,
464 })
465 }
466}
467
468#[cfg(feature = "native")]
473impl Doc {
474 pub fn open(path: &Path, opts: &Options) -> Result<Doc> {
480 if path.is_dir() {
481 let fsopts = quarb_fs::FsOptions {
482 hidden: opts.hidden,
483 respect_ignore: opts.respect_ignore,
484 };
485 let fs = quarb_fs::FsAdapter::with_options(path, fsopts)
486 .with_context(|| format!("opening directory {}", path.display()))?;
487 return Ok(if opts.descend {
488 Doc::FsDeep(quarb_compose::ComposeAdapter::with_source_paths(
489 fs,
490 |fs, n| Some(fs.path(n)),
491 ))
492 } else {
493 Doc::Fs(fs)
494 });
495 }
496
497 let s = path.to_string_lossy();
498 if let Some(repo) = s.strip_prefix("git:") {
499 let a =
500 quarb_git::GitAdapter::open(Path::new(repo)).context("opening git repository")?;
501 return Ok(Doc::Git(a));
502 }
503 if let Some(rest) = s.strip_prefix("text:")
507 && !rest.is_empty()
508 {
509 let target = Path::new(rest);
510 let text = std::fs::read_to_string(target)
511 .with_context(|| format!("reading {}", target.display()))?;
512 let text = text
513 .strip_prefix('\u{feff}')
514 .map(str::to_owned)
515 .unwrap_or(text);
516 let format = match target
517 .extension()
518 .and_then(|e| e.to_str())
519 .map(|e| e.to_ascii_lowercase())
520 .as_deref()
521 {
522 Some("html" | "htm") => "text-html",
523 Some("md" | "markdown") => "text-markdown",
524 Some("txt") => "text",
525 _ if text.trim_start().starts_with('<') => "text-html",
526 _ => "text",
527 };
528 return Doc::parse(&text, format);
529 }
530 if let Some(rest) = s.strip_prefix("code:")
535 && !rest.is_empty()
536 {
537 let target = Path::new(rest);
538 if target.is_dir() {
539 let fsopts = quarb_fs::FsOptions {
540 hidden: opts.hidden,
541 respect_ignore: opts.respect_ignore,
542 };
543 let fs = quarb_fs::FsAdapter::with_options(target, fsopts)
544 .with_context(|| format!("opening directory {}", target.display()))?;
545 return Ok(Doc::FsDeep(
546 quarb_compose::ComposeAdapter::with_source_paths(fs, |fs, n| {
547 Some(fs.path(n))
548 })
549 .with_source_graft(quarb_compose::SourceGraft::Code),
550 ));
551 }
552 let a = quarb_code::CodeModel::open(target)
553 .with_context(|| format!("parsing {} at the code level", target.display()))?;
554 return Ok(Doc::Code(a));
555 }
556
557 let ext = path
558 .extension()
559 .and_then(|e| e.to_str())
560 .map(|e| e.to_ascii_lowercase());
561
562 if let Some(e) = &ext
563 && quarb_tree_sitter::supported(e)
564 {
565 let a = quarb_tree_sitter::TreeSitterAdapter::open(path).context("parsing source file")?;
566 return Ok(Doc::Syntax(a));
567 }
568 if matches!(ext.as_deref(), Some("xlsx" | "xls" | "ods")) {
569 let a = quarb_xlsx::XlsxAdapter::open(path).context("opening workbook")?;
570 return Ok(Doc::Xlsx(a));
571 }
572 if is_sqlite(path) {
573 #[cfg(not(feature = "sqlite"))]
577 anyhow::bail!(
578 "{}: this quarb-session was built without the `sqlite` feature",
579 path.display()
580 );
581 #[cfg(feature = "sqlite")]
582 {
583 let a = quarb_sqlite::SqliteAdapter::open_with_refs(path, &opts.refs)
584 .context("opening SQLite database")?;
585 return Ok(Doc::Sqlite(a));
586 }
587 }
588 if is_archive(path) {
589 let a = quarb_archive::ArchiveAdapter::open(path).context("opening archive")?;
590 return Ok(Doc::Archive(quarb_compose::ComposeAdapter::new(a)));
591 }
592
593 let text = std::fs::read_to_string(path)
595 .with_context(|| format!("reading {}", path.display()))?;
596 let text = text
597 .strip_prefix('\u{feff}')
598 .map(str::to_owned)
599 .unwrap_or(text);
600 match ext.as_deref() {
601 Some("csv") => Doc::parse(&text, "csv"),
602 Some("tsv") => Doc::parse(&text, "tsv"),
603 Some("yaml" | "yml") => Doc::parse(&text, "yaml"),
604 Some("toml") => Doc::parse(&text, "toml"),
605 Some("md" | "markdown") => Doc::parse(&text, "markdown"),
606 Some("txt") => Doc::parse(&text, "text"),
607 Some("jsonl" | "ndjson") => Doc::parse(&text, "jsonl"),
608 _ => {
609 if is_xml(path, &text) {
610 Doc::parse(&text, "xml")
611 } else if is_html(path, &text) {
612 Doc::parse(&text, "html")
613 } else {
614 Doc::parse(&text, "json")
615 }
616 }
617 }
618 }
619
620 pub fn mount(paths: &[std::path::PathBuf], opts: &Options) -> Result<Doc> {
624 let specs: Vec<crate::MountSpec> = paths
625 .iter()
626 .map(|p| crate::MountSpec {
627 name: None,
628 path: p.clone(),
629 })
630 .collect();
631 Doc::mount_specs(&specs, opts)
632 }
633
634 pub fn mount_specs(specs: &[crate::MountSpec], opts: &Options) -> Result<Doc> {
637 let mut mounts: Vec<quarb_mount::Mount> = Vec::new();
638 for (i, spec) in specs.iter().enumerate() {
639 let name = spec.name.clone().unwrap_or_else(|| {
640 spec.path
641 .file_stem()
642 .map(|s| s.to_string_lossy().into_owned())
643 .unwrap_or_else(|| format!("doc{i}"))
644 });
645 if mounts.iter().any(|m| m.name == name) {
646 bail!(
647 "input '{}' mounts as '{name}', colliding with an earlier input of the \
648 same name; give each a distinct basename (or a NAME=TARGET alias)",
649 spec.path.display()
650 );
651 }
652 let adapter = Doc::open(&spec.path, opts)?.into_boxed()?;
653 mounts.push(quarb_mount::Mount {
654 name,
655 target: Some(spec.path.display().to_string()),
656 adapter,
657 });
658 }
659 Ok(Doc::Mount(quarb_mount::MountAdapter::new(mounts)))
660 }
661
662}
663
664fn generic_locator<A: quarb::AstAdapter>(a: &A, node: NodeId) -> String {
668 let mut parts = Vec::new();
669 let mut cur = Some(node);
670 while let Some(n) = cur {
671 if let Some(nm) = a.name(n) {
672 parts.push(nm);
673 }
674 cur = a.parent(n);
675 }
676 parts.reverse();
677 format!("/{}", parts.join("/"))
678}
679
680#[cfg(feature = "native")]
683fn is_sqlite(path: &Path) -> bool {
684 if path
685 .extension()
686 .and_then(|e| e.to_str())
687 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "db" | "sqlite" | "sqlite3"))
688 {
689 return true;
690 }
691 use std::io::Read as _;
692 let mut buf = [0u8; 16];
693 std::fs::File::open(path)
694 .and_then(|mut f| f.read_exact(&mut buf))
695 .is_ok()
696 && &buf == b"SQLite format 3\0"
697}
698
699#[cfg(feature = "native")]
701fn is_archive(path: &Path) -> bool {
702 if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
703 matches!(
704 e.to_ascii_lowercase().as_str(),
705 "zip" | "tar" | "gz" | "tgz" | "jar" | "war" | "docx" | "pptx" | "odt" | "odp"
706 )
707 }) {
708 return true;
709 }
710 use std::io::Read as _;
711 let mut buf = [0u8; 2];
712 std::fs::File::open(path)
713 .and_then(|mut f| f.read_exact(&mut buf))
714 .is_ok()
715 && (&buf == b"PK" || buf == [0x1f, 0x8b])
716}
717
718#[cfg(feature = "native")]
721fn is_xml(path: &Path, text: &str) -> bool {
722 path.extension()
723 .and_then(|e| e.to_str())
724 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "xml" | "svg" | "xhtml"))
725 || text.trim_start().starts_with("<?xml")
726}
727
728#[cfg(feature = "native")]
731fn is_html(path: &Path, text: &str) -> bool {
732 path.extension()
733 .and_then(|e| e.to_str())
734 .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "html" | "htm"))
735 || text.trim_start().starts_with('<')
736}