Skip to main content

omp_tui/
markup.rs

1//! Parse-once html-like markup for declarative component trees.
2//!
3//! Grammar (subset, grows with the component zoo):
4//! `<col>`, `<row>`, `<box>`, `<text>`, `<md>`, `<latex>`, `<pre>`,
5//! `<hr/>`, `<spacer/>` with attributes `gap`, `pad="y x"`, `grow[=n]`,
6//! `w=N|N%`, `min`, `max`, `h` (fixed height — a layout boundary),
7//! `border=square|round|heavy|double|dash` (paints a frame on `<box>`,
8//! `<row>`, and `<col>`, insetting children by one cell), `bc=`/`edge=`
9//! (border color), `title="…"`/`footer="…"` (frame labels, each with a
10//! `-align=left|center|right` variant), `align`, `valign`, `justify`, the
11//! `wrap` and `truncate` flags, color tokens (`accent`, `muted`, …), and the
12//! flag styles `bold`/`dim`/`italic`/`underline`/`strike`/`reverse`.
13//! Attributes are not per-tag: each applies wherever its axis exists.
14//! `<row>` lays children side by side, `<col>` stacks them, and both honor
15//! `pad`/`gap`. `align` positions along the writing axis — inside a row it
16//! distributes leftover width, inside a column it shrink-wraps and
17//! positions each child. `valign` is the cross axis, and says how a
18//! container places *its own* children: `start`, `center`, `end`, or
19//! `stretch`. A row stretches by default (flex `align-items: stretch`), so
20//! a `bg=` panel fills its share of the line; `valign=start` opts out.
21//! `justify` distributes leftover row width: `center`, `end`, or
22//! `between`, which expands the gaps so the first and last child hug the
23//! edges. A `wrap` row flows children onto as many lines as needed — each
24//! line holds as many children as fit and is solved and justified on its
25//! own, degrading to one child per line when nothing fits side by side.
26//! `truncate` clips a text child to one line with a trailing ellipsis
27//! instead of wrapping (`truncate=start` keeps the tail behind a leading
28//! ellipsis — ids and paths whose suffix matters), and cascades from the
29//! container to its implicit text children. Bare text lines inside a `<row>`
30//! are separate children laid side by side (consecutive `|` table lines stay
31//! one child); columns keep multi-line text as a single Markdown leaf.
32//! `grow` claims leftover space on the container's axis: width in a row,
33//! height in a column that has a fixed `h`. A `<hr/>` follows the axis
34//! too — a vertical separator inside a row, a horizontal divider anywhere
35//! else. `fg=` and `bg=` (alias `on=`) accept theme tokens and every CSS
36//! color form — named colors, `#`-hex, `rgb()`, `hsl()`, `hwb()`, `hsv()`,
37//! `lab()`, `lch()`, `oklab()`, `oklch()`, and `color()` — via the crate's
38//! `color` module.
39//! A two-stop value (`start..end`) makes a gradient; `angle=N` sets its
40//! direction in screen degrees (0 left-to-right, 90 top-to-bottom). Backgrounds
41//! are opt-in, so every element (boxes included) is transparent until `bg=`
42//! names one. On a framed container the fill stops inside the border; the
43//! `bleed` flag extends it behind the frame. Attribute values may
44//! be bare, `'single'`-, or `"double"`-quoted. Bare text between tags is an
45//! implicit Markdown leaf — every Markdown feature works anywhere text
46//! does, and `<text>` is the verbatim escape hatch. Markup owns `<` only
47//! for its own known tags outside Markdown code context, so `<em>` HTML,
48//! `<https://x>` autolinks, fences, code spans, math, comments, and
49//! backslash escapes behave exactly as they do inside `<md>`. Inside
50//! `<md>` only line-start block markup is recognized and interactive tags
51//! are rejected. Markdown indentation is measured from the enclosing
52//! tag's own column, not from column zero, so pretty-printing a document
53//! is structure rather than a chain of indented code blocks; a body is
54//! dedented by that column before it renders, and four columns past it
55//! still opens a code block. Agents never state cell
56//! geometry beyond optional hints; the layout engine owns measurement.
57//! `<ico:name/>` embeds a semantic icon anywhere text or a `title=` value
58//! appears; the active [`crate::Charset`] picks the glyph
59//! (unicode | nerd | ascii) and unknown names degrade to the bare name.
60//! `<pre>` is a verbatim block for ASCII/half-block art.
61//! `<table>` holds `<tr>` rows of `<td>` cells and solves every column
62//! once across all rows, so cells align vertically; surplus room goes to
63//! `grow` cells' columns and a deficit shrinks the widest flexible column
64//! first. `<td>` lays its children out side by side; with `truncate` it
65//! flattens text children into one styled line clipped by a single
66//! trailing ellipsis. Tables are layout-only — for an interactive list,
67//! `<option>` accepts the same `<td>` cells and the hosting `<select>`
68//! owns cursor, filter, hover, and activation over aligned rows.
69
70use omp_core::{Str, StrMut};
71
72use crate::{
73	component::{Cached, Component},
74	components::{
75		Boxed, Button, Callout, Col, CustomElement, EditorPane, Field, Form, Hr, Icon, Img, Input,
76		Latex, Markdown, Pre, Progress, Radio, Row, Scroll, Segment, Select, SelectOption, Spacer,
77		Spinner, Status, Table, TableCell, TableRow, Tabs, TaskStatus, TextLeaf, Todo, TodoTask,
78		Tree, TreeNode, Wizard,
79	},
80	context::{Charset, UiContext},
81	props::{Prop, PropValue, Props},
82};
83
84/// Border glyph set for framed containers (`<box>`, bordered `<row>`/`<col>`).
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum Border {
87	/// Sharp corners: `┌ ┐ └ ┘`.
88	#[default]
89	Square,
90	/// Dashed strokes: `╌ ┆`.
91	Dash,
92	/// Rounded corners: `╭ ╮ ╰ ╯`.
93	Round,
94	/// Heavy strokes: `┏ ┓ ┗ ┛`.
95	Heavy,
96	/// Double strokes: `╔ ╗ ╚ ╝`.
97	Double,
98}
99
100/// Width request for a row child.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum Dim {
103	/// Fixed cell hint.
104	Cells(u16),
105	/// Percentage of the row's content width.
106	Pct(u8),
107}
108
109/// Cross-axis content alignment.
110#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
111pub enum Align {
112	#[default]
113	Start,
114	Center,
115	End,
116}
117/// Main-axis distribution for a row's children.
118#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
119pub enum Justify {
120	#[default]
121	Start,
122	Center,
123	End,
124	Between,
125}
126
127/// Which end of a truncated line is clipped away.
128#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
129pub enum Truncate {
130	/// Keep the head; a trailing ellipsis replaces the overflow.
131	#[default]
132	End,
133	/// Keep the tail; a leading ellipsis replaces the overflow — ids and
134	/// paths whose distinctive part is the suffix.
135	Start,
136}
137
138/// How a container distributes leftover vertical space among its children.
139///
140/// Unset means "inherit the axis default": a `<row>` stretches its children
141/// to the tallest one (like flex `align-items: stretch`), every other
142/// container leaves them at the top.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum VAlign {
145	/// Children keep their own height, at the top.
146	Start,
147	/// Centered in the available height.
148	Center,
149	/// Flush with the bottom.
150	End,
151	/// Every child's rect spans the full height, so a `bg=` panel fills it.
152	Stretch,
153}
154
155/// Markup rejection with byte position context.
156#[derive(Debug)]
157pub struct ParseError {
158	/// Human-readable failure description.
159	pub message: String,
160	/// Byte offset into the source.
161	pub at:      usize,
162}
163
164impl std::fmt::Display for ParseError {
165	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166		write!(f, "markup error at byte {}: {}", self.at, self.message)
167	}
168}
169
170impl std::error::Error for ParseError {}
171
172/// Parses markup into a retained component tree rooted at a [`Col`].
173pub fn parse(source: &Str, ctx: &UiContext) -> Result<Cached, ParseError> {
174	let mut parser = Parser { source, src: source, ctx, fragment: false };
175	let (parts, _) = parser.parse_children(0, None, false, 0, "col", &Props::new())?;
176	let children = cached_children(parts, "col")?;
177	let root = build("col", Props::new(), children, &Str::default())
178		.expect("the root col is a catalog component");
179	Ok(Cached::new(root))
180}
181
182pub fn parse_md_fragment_inheriting(
183	text: &Str,
184	ctx: &UiContext,
185	host: &Props,
186) -> Result<Vec<Cached>, ParseError> {
187	if text.is_empty() {
188		return Ok(Vec::new());
189	}
190	let inherited = child_props(host);
191	let mut parser = Parser { source: text, src: text, ctx, fragment: true };
192	let (first, mut children, _) = parser.scan_md(0, 0, &inherited, false)?;
193	children.insert(0, markdown_part(first, inherited));
194	Ok(children)
195}
196
197#[allow(
198	clippy::large_enum_variant,
199	reason = "parser nodes move once into their final owners; boxing the larger variants would add \
200	          one allocation per parsed node"
201)]
202enum Parsed {
203	Cached { cached: Box<Cached>, text: Option<Str>, at: usize, implicit: bool },
204	Option { option: SelectOption, at: usize },
205	Segment { segment: Segment, at: usize },
206	Tab { title: Str, children: Vec<Cached>, at: usize },
207	TreeItem { node: TreeNode, at: usize },
208	Task { task: TodoTask, at: usize },
209	Field { field: Field, at: usize },
210	Step { title: Str, children: Vec<Cached>, at: usize },
211	TableRow { row: Box<TableRow>, at: usize },
212	Cell { cell: TableCell, at: usize },
213}
214
215impl Parsed {
216	fn into_cached(self, parent: &str) -> Result<Cached, ParseError> {
217		match self {
218			Self::Cached { cached, .. } => Ok(*cached),
219			Self::Option { at, .. } => Err(parent_error("option", parent, at)),
220			Self::Segment { at, .. } => Err(parent_error("segment", parent, at)),
221			Self::Tab { at, .. } => Err(parent_error("tab", parent, at)),
222			Self::TreeItem { at, .. } => Err(parent_error("node", parent, at)),
223			Self::Task { at, .. } => Err(parent_error("task", parent, at)),
224			Self::Field { at, .. } => Err(parent_error("field", parent, at)),
225			Self::Step { at, .. } => Err(parent_error("step", parent, at)),
226			Self::TableRow { at, .. } => Err(parent_error("tr", parent, at)),
227			Self::Cell { at, .. } => Err(parent_error("td", parent, at)),
228		}
229	}
230
231	const fn name(&self) -> &'static str {
232		match self {
233			Self::Cached { .. } => "content",
234			Self::Option { .. } => "option",
235			Self::Segment { .. } => "segment",
236			Self::Tab { .. } => "tab",
237			Self::TreeItem { .. } => "node",
238			Self::Task { .. } => "task",
239			Self::Field { .. } => "field",
240			Self::Step { .. } => "step",
241			Self::TableRow { .. } => "tr",
242			Self::Cell { .. } => "td",
243		}
244	}
245}
246
247fn parent_error(tag: &str, parent: &str, at: usize) -> ParseError {
248	ParseError { message: format!("<{tag}> is not allowed directly inside <{parent}>"), at }
249}
250
251fn cached_children(parts: Vec<Parsed>, parent: &str) -> Result<Vec<Cached>, ParseError> {
252	parts
253		.into_iter()
254		.map(|part| part.into_cached(parent))
255		.collect()
256}
257
258struct Parser<'a> {
259	source:   &'a Str,
260	src:      &'a str,
261	ctx:      &'a UiContext,
262	fragment: bool,
263}
264
265impl Parser<'_> {
266	/// Scans a container body after all literal Markdown guards have run.
267	fn parse_children(
268		&mut self,
269		body_start: usize,
270		closing: Option<&str>,
271		restricted: bool,
272		indent: usize,
273		parent_tag: &str,
274		parent_props: &Props,
275	) -> Result<(Vec<Parsed>, usize), ParseError> {
276		let mut parts = Vec::new();
277		let mut segment_start = body_start;
278		let mut at = body_start;
279		let mut fence = FenceScan::segment(indent);
280		while at < self.src.len() {
281			let Some(offset) = self.src[at..].find('<') else {
282				break;
283			};
284			fence.consume(&self.src[at..at + offset]);
285			at += offset;
286			if self.src[at..].starts_with("<!--") {
287				let skip = self.src[at + 4..]
288					.find("-->")
289					.map_or(1, |end| end + 4 + "-->".len());
290				fence.skip(&self.src[at..at + skip]);
291				at += skip;
292				continue;
293			}
294			let name = tag_name(&self.src[at + 1..]);
295			let escaped = self.src[segment_start..at]
296				.bytes()
297				.rev()
298				.take_while(|&byte| byte == b'\\')
299				.count() % 2
300				== 1;
301			let literal = escaped
302				|| fence.in_code()
303				|| fence.in_code_span(&self.src[at..])
304				|| in_math_span(&self.src[segment_start..], at - segment_start);
305			if literal || name.is_none() {
306				fence.consume(&self.src[at..=at]);
307				at += 1;
308				continue;
309			}
310			let name = name.unwrap_or_default();
311			let Some(close) = tag_close(&self.src[at + 1..]).map(|end| end + at + 1) else {
312				break;
313			};
314			let is_closing = self.src[at + 1..].starts_with('/');
315			let raw = &self.src[at + 1..close];
316			let self_closing = raw.ends_with('/');
317			let catalog = is_catalog_tag(name);
318			let custom = !catalog
319				&& !is_markdown_html_tag(name)
320				&& if is_closing {
321					closing == Some(name)
322				} else {
323					self_closing || has_matching_close(&self.src[close + 1..], name)
324				};
325			if !catalog && !custom {
326				fence.consume(&self.src[at..=at]);
327				at += 1;
328				continue;
329			}
330			if is_closing {
331				if closing != Some(name) {
332					return Err(ParseError {
333						message: format!(
334							"closing </{name}> does not match open <{}>",
335							closing.unwrap_or("nothing")
336						),
337						at,
338					});
339				}
340				self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
341				return Ok((parts, close + 1));
342			}
343			self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
344			let (part, next) = self.parse_element(at, close, restricted, parent_props)?;
345			parts.push(part);
346			at = next;
347			segment_start = at;
348			fence = FenceScan::segment(indent);
349		}
350		if closing.is_some() {
351			return Err(ParseError {
352				message: format!("unclosed <{}> tag", closing.unwrap_or_default()),
353				at:      self.src.len(),
354			});
355		}
356		self.add_text(&mut parts, parent_tag, parent_props, segment_start, self.src.len(), indent);
357		Ok((parts, self.src.len()))
358	}
359
360	fn parse_element(
361		&mut self,
362		at: usize,
363		close: usize,
364		restricted: bool,
365		inherited: &Props,
366	) -> Result<(Parsed, usize), ParseError> {
367		let raw = &self.src[at + 1..close];
368		let self_closing = raw.ends_with('/');
369		let raw = raw.strip_suffix('/').unwrap_or(raw);
370		let (name, attrs) = raw.split_once(char::is_whitespace).unwrap_or((raw, ""));
371		let indent = leading_spaces(line_of(self.src, at));
372		if restricted && is_interactive_tag(name) {
373			return Err(ParseError {
374				message: format!("interactive tag <{name}> is not allowed inside <md>"),
375				at,
376			});
377		}
378		let mut props = apply_attrs(attrs, at, self.source, self.ctx, inherited)?;
379		let tag = self.source.slice_ref(name);
380		let name = tag.as_str();
381		if self.fragment && (props.get(Prop::Id).is_some() || props.get(Prop::When).is_some()) {
382			return Err(ParseError {
383				message: "id= and when= are not allowed in dynamic Markdown".into(),
384				at,
385			});
386		}
387		if name == "box" {
388			if props.get(Prop::Border).is_none() {
389				props
390					.try_set(Prop::Border, PropValue::Border(Border::Square))
391					.unwrap();
392			}
393			if props.get(Prop::PadX).is_none() {
394				props.try_set(Prop::PadX, PropValue::U16(1)).unwrap();
395			}
396		} else if name == "spacer" && props.get(Prop::Grow).is_none() {
397			props.try_set(Prop::Grow, PropValue::F32(1.0)).unwrap();
398		}
399		let body_start = close + 1;
400		if self_closing {
401			return finish_element(name, props, Vec::new(), Str::default(), at)
402				.map(|part| (part, body_start));
403		}
404		if matches!(name, "pre" | "latex" | "callout") {
405			let closer = match name {
406				"pre" => "</pre>",
407				"latex" => "</latex>",
408				"callout" => "</callout>",
409				_ => unreachable!(),
410			};
411			let end = self.src[body_start..]
412				.find(closer)
413				.map_or(self.src.len(), |offset| body_start + offset);
414			let trim: &[_] = if name == "pre" {
415				&['\n', '\r']
416			} else {
417				&['\n']
418			};
419			let body = self.src[body_start..end].trim_matches(trim);
420			let body = self.source.slice_ref(body);
421			let part = finish_element(name, props, Vec::new(), body, at)?;
422			return Ok((part, (end + closer.len()).min(self.src.len())));
423		}
424		if matches!(name, "text" | "button" | "icon") {
425			let closer = match name {
426				"text" => "</text>",
427				"button" => "</button>",
428				"icon" => "</icon>",
429				_ => unreachable!(),
430			};
431			let end = self.src[body_start..]
432				.find(closer)
433				.map(|offset| body_start + offset)
434				.ok_or_else(|| ParseError {
435					message: format!("unclosed <{name}> tag"),
436					at:      self.src.len(),
437				})?;
438			let body = self
439				.source
440				.slice_ref(self.src[body_start..end].trim_matches(['\n', '\r']));
441			let part = finish_element(name, props, Vec::new(), body, at)?;
442			return Ok((part, end + closer.len()));
443		}
444		if name == "md" {
445			return self.parse_md(body_start, indent, props, true);
446		}
447		if is_leaf_tag(name) {
448			return finish_element(name, props, Vec::new(), Str::default(), at)
449				.map(|part| (part, body_start));
450		}
451		let child_props = child_props(&props);
452		let (parts, end) =
453			self.parse_children(body_start, Some(name), restricted, indent, name, &child_props)?;
454		let part = finish_element(name, props, parts, Str::default(), at)?;
455		Ok((part, end))
456	}
457
458	fn parse_md(
459		&mut self,
460		body_start: usize,
461		indent: usize,
462		props: Props,
463		require_close: bool,
464	) -> Result<(Parsed, usize), ParseError> {
465		let (text, children, end) = self.scan_md(body_start, indent, &props, require_close)?;
466		Ok((markdown_with_parts(props, text, children, body_start, false), end))
467	}
468
469	fn scan_md(
470		&mut self,
471		body_start: usize,
472		indent: usize,
473		props: &Props,
474		require_close: bool,
475	) -> Result<(Str, Vec<Cached>, usize), ParseError> {
476		let mut segment_start = body_start;
477		let mut first = true;
478		let mut first_text = Str::default();
479		let mut embedded = Vec::new();
480		let child_props = child_props(props);
481		loop {
482			let event = self.next_md_event(segment_start, indent, require_close)?;
483			let (end, element) = match event {
484				MdEvent::Close(at) | MdEvent::End(at) => (at, None),
485				MdEvent::Element(at, close) => (at, Some((at, close))),
486			};
487			let body = self.src[segment_start..end].trim_matches('\n');
488			let text = dedent(self.source, body, indent);
489			if first {
490				first_text = text;
491				first = false;
492			} else {
493				embedded.push(markdown_part(text, child_props.clone()));
494			}
495			let Some((at, close)) = element else {
496				let next = if require_close {
497					end + "</md>".len()
498				} else {
499					end
500				};
501				return Ok((first_text, embedded, next));
502			};
503			let (part, next) = self.parse_element(at, close, true, &child_props)?;
504			embedded.push(part.into_cached("md")?);
505			segment_start = next;
506		}
507	}
508
509	fn next_md_event(
510		&self,
511		mut at: usize,
512		indent: usize,
513		require_close: bool,
514	) -> Result<MdEvent, ParseError> {
515		let mut fence: Option<(u8, usize)> = None;
516		while at < self.src.len() {
517			let line_end = self.src[at..].find('\n').map_or(self.src.len(), |p| p + at);
518			let line = self.src[at..line_end]
519				.strip_suffix('\r')
520				.unwrap_or_else(|| &self.src[at..line_end]);
521			let trimmed = line.trim_start();
522			let prefix = &line[..line.len() - trimmed.len()];
523			let spaces = prefix.as_bytes().iter().take_while(|&&b| b == b' ').count();
524			let indented_code = spaces >= indent + 4 || prefix.contains('\t');
525			if let Some((marker, length)) = fence {
526				let run = trimmed
527					.as_bytes()
528					.iter()
529					.take_while(|&&b| b == marker)
530					.count();
531				if run >= length {
532					fence = None;
533					if let Some(pos) = trimmed[run..].find("</md>") {
534						let close_at = at + (line.len() - trimmed.len()) + run + pos;
535						if require_close {
536							return Ok(MdEvent::Close(close_at));
537						}
538						return Err(stray_md_close(close_at));
539					}
540				}
541			} else {
542				let marker = trimmed.as_bytes().first().copied();
543				let run =
544					marker.map_or(0, |m| trimmed.as_bytes().iter().take_while(|&&b| b == m).count());
545				if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented_code {
546					fence = Some((marker.unwrap_or_default(), run));
547				} else {
548					let tag_at = at + (line.len() - trimmed.len());
549					if !indented_code && let Some((name, close)) = line_tag(trimmed, tag_at) {
550						if is_md_block_tag(name) || is_custom_tag_at(self.src, name, tag_at, close) {
551							return Ok(MdEvent::Element(tag_at, close));
552						}
553						if is_interactive_tag(name) {
554							return Err(ParseError {
555								message: format!("interactive tag <{name}> is not allowed inside <md>"),
556								at:      tag_at,
557							});
558						}
559					}
560					if let Some(close) = line.find("</md>") {
561						let close_at = at + close;
562						if require_close {
563							return Ok(MdEvent::Close(close_at));
564						}
565						return Err(stray_md_close(close_at));
566					}
567				}
568			}
569			at = if line_end < self.src.len() {
570				line_end + 1
571			} else {
572				line_end
573			};
574		}
575		if require_close {
576			Err(ParseError { message: "unclosed <md> tag".into(), at: self.src.len() })
577		} else {
578			Ok(MdEvent::End(self.src.len()))
579		}
580	}
581
582	fn add_text(
583		&self,
584		parts: &mut Vec<Parsed>,
585		parent_tag: &str,
586		parent_props: &Props,
587		start: usize,
588		end: usize,
589		indent: usize,
590	) {
591		let raw = &self.src[start..end];
592		if raw.trim().is_empty() {
593			return;
594		}
595		// The full clone already carries `truncate` (including its
596		// clipped-side value) into implicit text children.
597		let props = parent_props.clone();
598		if parent_tag == "row" {
599			let mut table_start: Option<usize> = None;
600			let mut offset = 0;
601			for line in raw.split_inclusive('\n') {
602				let content = line.trim();
603				let at = start + offset;
604				offset += line.len();
605				if content.starts_with('|') {
606					table_start.get_or_insert(at);
607					continue;
608				}
609				if let Some(from) = table_start.take() {
610					let chunk = self.src[from..at].trim_matches(['\n', '\r']);
611					if !chunk.trim().is_empty() {
612						let text = dedent(self.source, chunk, indent);
613						parts.push(markdown_parsed(text, props.clone(), from));
614					}
615				}
616				if !content.is_empty() {
617					let text = self.source.slice_ref(content);
618					parts.push(markdown_parsed(text, props.clone(), at));
619				}
620			}
621			if let Some(from) = table_start {
622				let chunk = self.src[from..end].trim_matches(['\n', '\r']);
623				if !chunk.trim().is_empty() {
624					let text = dedent(self.source, chunk, indent);
625					parts.push(markdown_parsed(text, props, from));
626				}
627			}
628		} else {
629			let text = dedent(self.source, raw.trim_matches(['\n', '\r']), indent);
630			parts.push(markdown_parsed(text, props, start));
631		}
632	}
633}
634/// Removes up to `indent` leading spaces from every line, so a body's
635/// Markdown indentation is measured from the tag that encloses it and a
636/// pretty-printed document does not read as a code block.
637///
638/// Zero-copy when there is nothing to strip.
639fn dedent(source: &Str, body: &str, indent: usize) -> Str {
640	if indent == 0 || !body.lines().any(|line| line.starts_with(' ')) {
641		return source.slice_ref(body);
642	}
643	let mut out = StrMut::with_capacity(body.len());
644	for (index, line) in body.split('\n').enumerate() {
645		if index > 0 {
646			out.push_str("\n");
647		}
648		out.push_str(&line[leading_spaces(line).min(indent)..]);
649	}
650	out.freeze()
651}
652
653/// Leading spaces of `line`.
654fn leading_spaces(line: &str) -> usize {
655	line.bytes().take_while(|byte| *byte == b' ').count()
656}
657
658/// The line `at` sits on, from its first byte up to `at`.
659///
660/// Only the indentation matters to callers, so the tail is not scanned.
661fn line_of(src: &str, at: usize) -> &str {
662	let start = src[..at].rfind('\n').map_or(0, |newline| newline + 1);
663	&src[start..at]
664}
665
666/// Tracks Markdown code state across the text runs of an implicit body —
667/// fenced blocks and inline code spans — so their `<` stays literal.
668struct FenceScan {
669	fence:         Option<(u8, usize)>,
670	/// Backtick-run length of an open inline code span.
671	code:          Option<usize>,
672	/// The current line is an indented code line.
673	indented:      bool,
674	/// Column of the tag that opened this body. Markdown indentation is
675	/// measured from here, so pretty-printing a document does not turn its
676	/// children into code blocks.
677	indent:        usize,
678	at_line_start: bool,
679}
680
681impl FenceScan {
682	/// Scanner for a fresh Markdown segment inside a tag at column
683	/// `indent`. Each implicit segment renders as its own document, so its
684	/// first byte begins a line — a fence may open immediately after the
685	/// enclosing tag.
686	const fn segment(indent: usize) -> Self {
687		Self { fence: None, code: None, indented: false, indent, at_line_start: true }
688	}
689
690	/// Whether Markdown owns this position's code context — a fenced block
691	/// or a code line indented four columns past the enclosing tag.
692	const fn in_code(&self) -> bool {
693		self.fence.is_some() || self.indented
694	}
695
696	/// Whether an inline code span is open here *and* closes later, so an
697	/// unmatched backtick never swallows the rest of the body.
698	///
699	/// The lookahead mirrors [`crate::markdown`]'s own `code_span`: any
700	/// equal-length backtick run closes it, newlines included — a span may
701	/// cross blank lines.
702	fn in_code_span(&self, tail: &str) -> bool {
703		let Some(length) = self.code else {
704			return false;
705		};
706		let bytes = tail.as_bytes();
707		let mut at = 0;
708		while at < bytes.len() {
709			if bytes[at] != b'`' {
710				at += 1;
711				continue;
712			}
713			let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
714			if run == length {
715				return true;
716			}
717			at += run;
718		}
719		false
720	}
721
722	/// Advances line state over text Markdown removes before parsing (HTML
723	/// comments), without letting its content toggle code state.
724	fn skip(&mut self, text: &str) {
725		for piece in text.split_inclusive('\n') {
726			self.at_line_start = piece.ends_with('\n');
727			if self.at_line_start {
728				self.indented = false;
729			}
730		}
731	}
732
733	fn consume(&mut self, run: &str) {
734		for piece in run.split_inclusive('\n') {
735			let line = piece.trim_end_matches(['\n', '\r']);
736			if self.at_line_start {
737				// four spaces past the enclosing tag start an indented code
738				// line (renderer's is_indented_code); it holds for the line
739				self.indented = leading_spaces(line) >= self.indent + 4;
740			}
741			let fenced = self.at_line_start && self.fence_marker(line);
742			if !fenced && self.fence.is_none() {
743				self.code_spans(line);
744			}
745			self.at_line_start = piece.ends_with('\n');
746			if self.at_line_start {
747				// the next line's indent is unknown until it is scanned, so
748				// a tag at column 0 never inherits this line's
749				self.indented = false;
750			}
751		}
752	}
753
754	/// Opens or closes a fenced block, reporting whether `line` was a fence
755	/// marker. The rules mirror [`crate::markdown`]'s `fence_start` /
756	/// `is_closing_fence`: ≤3 spaces of indent, a run of 3+ backticks or
757	/// tildes to open (info string allowed), and a same-marker run at
758	/// least as long with a whitespace-only remainder to close.
759	fn fence_marker(&mut self, line: &str) -> bool {
760		let trimmed = line.trim_start_matches(' ');
761		if line.len().saturating_sub(trimmed.len()) > 3 {
762			return false;
763		}
764		let Some(marker) = trimmed.as_bytes().first().copied() else {
765			return false;
766		};
767		let run = trimmed.bytes().take_while(|&byte| byte == marker).count();
768		if let Some((open, length)) = self.fence {
769			let closes = marker == open && run >= length && trimmed[run..].trim().is_empty();
770			if closes {
771				self.fence = None;
772			}
773			return closes;
774		}
775		let opens = matches!(marker, b'`' | b'~') && run >= 3;
776		if opens {
777			self.fence = Some((marker, run));
778			self.code = None;
779		}
780		opens
781	}
782
783	/// Toggles inline code spans: a run of N backticks opens, an equal run
784	/// closes.
785	fn code_spans(&mut self, line: &str) {
786		let bytes = line.as_bytes();
787		let mut at = 0;
788		while at < bytes.len() {
789			if bytes[at] != b'`' {
790				at += 1;
791				continue;
792			}
793			let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
794			match self.code {
795				Some(length) if length == run => self.code = None,
796				Some(_) => {},
797				None => self.code = Some(run),
798			}
799			at += run;
800		}
801	}
802}
803
804/// Whether `offset` falls inside a Markdown math span of `text`. Uses the
805/// renderer's own scanner, so `$$…$$`, `\[…\]`, `\(…\)`, and the
806/// anti-currency `$…$` rule stay in one place.
807fn in_math_span(text: &str, offset: usize) -> bool {
808	let mut at = 0;
809	while at < offset {
810		let Some(next) = text[at..].find(['$', '\\']) else {
811			return false;
812		};
813		let start = at + next;
814		if start >= offset {
815			return false;
816		}
817		match crate::markdown::math_span(&text[start..]) {
818			Some((_, consumed)) if offset < start + consumed => return true,
819			Some((_, consumed)) => at = start + consumed,
820			None => at = start + 1,
821		}
822	}
823	false
824}
825
826/// The tag name opening just past a `<`, or `None` when the payload isn't
827/// a plain identifier followed by whitespace, `/>`, or `>` — `<https://x>`
828/// and `<a@b.com>` are Markdown autolinks, not malformed tags. Scans only
829/// a prefix, so tag ownership is decided before the (quote-aware) closing
830/// `>` is located.
831fn tag_name(after: &str) -> Option<&str> {
832	let after = after.strip_prefix('/').unwrap_or(after);
833	let end = after
834		.find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
835		.unwrap_or(after.len());
836	let name = &after[..end];
837	let rest = &after[end..];
838	let delimited =
839		rest.starts_with('>') || rest.starts_with("/>") || rest.starts_with(char::is_whitespace);
840	(delimited && name.starts_with(|character: char| character.is_ascii_alphabetic()))
841		.then_some(name)
842}
843
844/// Byte offset of the `>` closing the tag payload `after` (just past its
845/// `<`), skipping `"…"`/`'…'` quoted attribute values so a title may embed
846/// `<ico:name/>`. `None` when unterminated.
847const fn tag_close(after: &str) -> Option<usize> {
848	let bytes = after.as_bytes();
849	let mut index = 0;
850	while index < bytes.len() {
851		match bytes[index] {
852			b'>' => return Some(index),
853			quote @ (b'"' | b'\'') => {
854				index += 1;
855				while index < bytes.len() && bytes[index] != quote {
856					index += 1;
857				}
858				if index == bytes.len() {
859					return None;
860				}
861				index += 1;
862			},
863			_ => index += 1,
864		}
865	}
866	None
867}
868
869/// The `<ico:name/>` tag at `text`'s start: `(name, consumed bytes)`.
870///
871/// Shared between attribute resolution here and Markdown inline rendering,
872/// so the icon grammar can never fork. Names accept the catalog's short
873/// keys and qualified aliases (`[A-Za-z0-9_.-]+`); the `/` is optional.
874pub fn ico_tag(text: &str) -> Option<(&str, usize)> {
875	let rest = text.strip_prefix("<ico:")?;
876	let end = rest
877		.find(|character: char| {
878			!(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
879		})
880		.unwrap_or(rest.len());
881	let name = &rest[..end];
882	let tail = rest[end..].trim_start_matches(' ');
883	let tail = tail.strip_prefix('/').unwrap_or(tail);
884	let tail = tail.strip_prefix('>')?;
885	(!name.is_empty()).then(|| (name, text.len() - tail.len()))
886}
887
888/// Substitutes every `<ico:name/>` in an attribute value through the
889/// charset; zero-copy when none are present. Unknown names keep the bare
890/// name — visible and greppable instead of silently dropped.
891fn resolve_icons(charset: Charset, source: &Str, value: &str) -> Str {
892	if !value.contains("<ico:") {
893		return source.slice_ref(value);
894	}
895	let mut resolved = StrMut::new("");
896	let mut rest = value;
897	while let Some(at) = rest.find("<ico:") {
898		if let Some((name, consumed)) = ico_tag(&rest[at..]) {
899			resolved.push_str(&rest[..at]);
900			resolved.push_str(charset.icon_named(name).unwrap_or(name));
901			rest = &rest[at + consumed..];
902		} else {
903			resolved.push_str(&rest[..at + "<ico:".len()]);
904			rest = &rest[at + "<ico:".len()..];
905		}
906	}
907	resolved.push_str(rest);
908	resolved.freeze()
909}
910
911enum MdEvent {
912	Close(usize),
913	Element(usize, usize),
914	End(usize),
915}
916
917fn line_tag(line: &str, at: usize) -> Option<(&str, usize)> {
918	let raw = line.strip_prefix('<')?;
919	let close = tag_close(raw)?;
920	let tag = raw[..close].strip_suffix('/').unwrap_or(&raw[..close]);
921	let name = tag
922		.split_once(char::is_whitespace)
923		.map_or(tag, |(name, _)| name);
924	Some((name, at + close + 1))
925}
926
927fn is_md_block_tag(name: &str) -> bool {
928	is_catalog_tag(name) && !is_interactive_tag(name)
929}
930
931fn is_markdown_html_tag(name: &str) -> bool {
932	matches!(name, "br" | "p" | "span" | "code" | "li" | "ul" | "ol" | "blockquote")
933}
934
935fn is_catalog_tag(name: &str) -> bool {
936	matches!(
937		name,
938		"col"
939			| "row"
940			| "box"
941			| "text"
942			| "pre"
943			| "md" | "latex"
944			| "hr" | "spacer"
945			| "select"
946			| "option"
947			| "radio"
948			| "spinner"
949			| "status"
950			| "segment"
951			| "input"
952			| "button"
953			| "scroll"
954			| "tabs"
955			| "tab"
956			| "tree"
957			| "node"
958			| "todo"
959			| "task"
960			| "form"
961			| "field"
962			| "progress"
963			| "img"
964			| "editor"
965			| "wizard"
966			| "step"
967			| "callout"
968			| "icon"
969			| "table"
970			| "tr" | "td"
971	)
972}
973
974fn is_interactive_tag(name: &str) -> bool {
975	matches!(
976		name,
977		"select"
978			| "option"
979			| "radio"
980			| "input"
981			| "button"
982			| "scroll"
983			| "tabs"
984			| "tab"
985			| "tree"
986			| "node"
987			| "form"
988			| "field"
989			| "editor"
990			| "wizard"
991			| "step"
992	)
993}
994
995fn is_leaf_tag(name: &str) -> bool {
996	matches!(name, "pre" | "hr" | "spacer" | "radio" | "input" | "progress" | "img")
997}
998
999fn has_matching_close(mut after: &str, name: &str) -> bool {
1000	while let Some(at) = after.find("</") {
1001		let tail = &after[at + 2..];
1002		if tail
1003			.strip_prefix(name)
1004			.is_some_and(|tail| tail.starts_with('>'))
1005		{
1006			return true;
1007		}
1008		after = tail;
1009	}
1010	false
1011}
1012
1013fn is_custom_tag_at(src: &str, name: &str, at: usize, close: usize) -> bool {
1014	!name.starts_with('/')
1015		&& !is_catalog_tag(name)
1016		&& !is_markdown_html_tag(name)
1017		&& (src[at..=close].trim_end().ends_with("/>") || has_matching_close(&src[close + 1..], name))
1018}
1019
1020fn stray_md_close(at: usize) -> ParseError {
1021	ParseError { message: "closing </md> does not match open <nothing>".into(), at }
1022}
1023
1024/// True when a dynamic `<md>` body embeds a line-start markup element
1025/// outside code fences.
1026pub fn md_embeds_markup(text: &str) -> bool {
1027	let mut fence: Option<(u8, usize)> = None;
1028	let mut at = 0;
1029	for piece in text.split_inclusive('\n') {
1030		let line = piece.strip_suffix('\n').unwrap_or(piece);
1031		let line = line.strip_suffix('\r').unwrap_or(line);
1032		let trimmed = line.trim_start();
1033		let prefix = &line[..line.len() - trimmed.len()];
1034		let spaces = prefix.bytes().take_while(|&b| b == b' ').count();
1035		let indented = spaces >= 4 || prefix.contains('\t');
1036		if let Some((marker, length)) = fence {
1037			let run = trimmed.bytes().take_while(|&b| b == marker).count();
1038			if run >= length {
1039				fence = None;
1040			}
1041			at += piece.len();
1042			continue;
1043		}
1044		let marker = trimmed.as_bytes().first().copied();
1045		let run = marker.map_or(0, |m| trimmed.bytes().take_while(|&b| b == m).count());
1046		if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented {
1047			fence = Some((marker.unwrap_or_default(), run));
1048			at += piece.len();
1049			continue;
1050		}
1051		let tag_at = at + prefix.len();
1052		if !indented
1053			&& let Some((name, close)) = line_tag(trimmed, tag_at)
1054			&& (is_md_block_tag(name)
1055				|| is_interactive_tag(name)
1056				|| is_custom_tag_at(text, name, tag_at, close))
1057		{
1058			return true;
1059		}
1060		at += piece.len();
1061	}
1062	false
1063}
1064
1065fn child_props(parent: &Props) -> Props {
1066	let mut child = Props::new();
1067	for prop in [
1068		Prop::Fg,
1069		Prop::Bold,
1070		Prop::Dim,
1071		Prop::Italic,
1072		Prop::Underline,
1073		Prop::Reverse,
1074		Prop::Strike,
1075		Prop::Truncate,
1076	] {
1077		if let Some(value) = parent.get(prop).cloned()
1078			&& !matches!(value, PropValue::Gradient(_))
1079		{
1080			child.try_set(prop, value).unwrap();
1081		}
1082	}
1083	child
1084}
1085
1086macro_rules! replay_props {
1087	($component:ident, $props:expr) => {
1088		for prop in <Prop as strum::IntoEnumIterator>::iter() {
1089			if let Some(value) = $props.get(prop).cloned() {
1090				$component = $component.with(prop, value);
1091			}
1092		}
1093	};
1094}
1095
1096fn boxed_component<T: Component + 'static>(mut component: T, props: Props) -> Box<dyn Component> {
1097	*component.props_mut() = props;
1098	Box::new(component)
1099}
1100
1101fn build(tag: &str, props: Props, children: Vec<Cached>, body: &Str) -> Option<Box<dyn Component>> {
1102	macro_rules! configured {
1103		($component:expr) => {{
1104			let mut component = $component;
1105			replay_props!(component, props);
1106			boxed_component(component, props)
1107		}};
1108	}
1109	Some(match tag {
1110		"col" => configured!(Col::new().child(children)),
1111		"row" => configured!(Row::new().child(children)),
1112		"box" => configured!(Boxed::new().child(children)),
1113		"text" => configured!(TextLeaf::new().text(body.clone())),
1114		"pre" => configured!(Pre::new().text(body.clone())),
1115		"md" => configured!(Markdown::text_of(body.clone()).child(children)),
1116		"latex" => configured!(Latex::new().text(body.clone())),
1117		"hr" => configured!(Hr::new()),
1118		"spacer" => configured!(Spacer::new()),
1119		"select" => configured!(Select::new()),
1120		"table" => configured!(Table::new()),
1121		"radio" => configured!(Radio::new()),
1122		"spinner" => configured!(Spinner::new().label(body.clone())),
1123		"input" => configured!(Input::new()),
1124		"button" => configured!(Button::new().child(body.clone())),
1125		"scroll" => configured!(Scroll::new().child(children)),
1126		"tabs" => configured!(Tabs::new().child(children)),
1127		"tree" => configured!(Tree::new()),
1128		"todo" => configured!(Todo::new()),
1129		"form" => configured!(Form::new()),
1130		"progress" => configured!(Progress::new()),
1131		"img" => configured!(Img::new()),
1132		"editor" => configured!(EditorPane::new()),
1133		"wizard" => configured!(Wizard::new().child(children)),
1134		"callout" => configured!(Callout::new().text(body.clone())),
1135		"icon" => {
1136			let name = if body.is_empty() {
1137				props.str_of(Prop::Icon).cloned().unwrap_or_default()
1138			} else {
1139				body.clone()
1140			};
1141			configured!(Icon::named(name))
1142		},
1143		"option" | "segment" | "tab" | "node" | "field" | "step" | "task" | "tr" | "td" => {
1144			return None;
1145		},
1146		_ => return None,
1147	})
1148}
1149
1150fn finish_element(
1151	tag: &str,
1152	props: Props,
1153	mut parts: Vec<Parsed>,
1154	body: Str,
1155	at: usize,
1156) -> Result<Parsed, ParseError> {
1157	match tag {
1158		"option" => {
1159			let label = take_label(&mut parts).unwrap_or_default();
1160			let mut option = SelectOption::new();
1161			replay_props!(option, props);
1162			if !label.is_empty() {
1163				option = option.label(label);
1164			}
1165			for part in parts {
1166				match part {
1167					// Cells become the option's aligned row; anything else
1168					// stays preview content shown beneath it.
1169					Parsed::Cell { cell, .. } => option = option.cell(cell),
1170					other => option = option.child(other.into_cached("option")?),
1171				}
1172			}
1173			Ok(Parsed::Option { option, at })
1174		},
1175		"td" => {
1176			let children = cached_children(parts, "td")?;
1177			let mut cell = TableCell::new().child(children);
1178			*cell.props_mut() = props;
1179			Ok(Parsed::Cell { cell, at })
1180		},
1181		"tr" => {
1182			let mut row = TableRow::new();
1183			replay_props!(row, props);
1184			for part in parts {
1185				match part {
1186					Parsed::Cell { cell, .. } => row = row.cell(cell),
1187					other => return Err(parent_error(other.name(), "tr", at)),
1188				}
1189			}
1190			Ok(Parsed::TableRow { row: Box::new(row), at })
1191		},
1192		"table" => {
1193			let mut table = Table::new();
1194			for part in parts {
1195				match part {
1196					Parsed::TableRow { row, .. } => table = table.row(*row),
1197					other => return Err(parent_error(other.name(), "table", at)),
1198				}
1199			}
1200			Ok(Parsed::Cached {
1201				cached: Box::new(Cached::new(boxed_component(table, props))),
1202				text: None,
1203				at,
1204				implicit: false,
1205			})
1206		},
1207		"segment" => {
1208			let label = take_label(&mut parts).unwrap_or_default();
1209			if let Some(other) = parts.into_iter().next() {
1210				return Err(parent_error(other.name(), "segment", at));
1211			}
1212			let mut segment = Segment::new();
1213			replay_props!(segment, props);
1214			if !label.is_empty() {
1215				segment = segment.label(label);
1216			}
1217			Ok(Parsed::Segment { segment, at })
1218		},
1219		"tab" => {
1220			let title = props.title().cloned().unwrap_or_else(|| Str::new("tab"));
1221			let children = cached_children(parts, "tab")?;
1222			Ok(Parsed::Tab { title, children, at })
1223		},
1224		"node" => {
1225			let body_label = take_label(&mut parts);
1226			let label = body_label
1227				.or_else(|| props.str_of(Prop::Label).cloned())
1228				.unwrap_or_default();
1229			let mut node = TreeNode::new();
1230			replay_props!(node, props);
1231			if !label.is_empty() {
1232				node = node.label(label);
1233			}
1234			for part in parts {
1235				match part {
1236					Parsed::TreeItem { node: child, .. } => node = node.node(child),
1237					other => return Err(parent_error(other.name(), "node", at)),
1238				}
1239			}
1240			Ok(Parsed::TreeItem { node, at })
1241		},
1242		"task" => {
1243			let body_label = take_label(&mut parts);
1244			let label = body_label
1245				.or_else(|| props.str_of(Prop::Label).cloned())
1246				.unwrap_or_default();
1247			if let Some(status) = props.str_of(Prop::Status)
1248				&& TaskStatus::parse(status).is_none()
1249			{
1250				return Err(ParseError {
1251					message: format!(
1252						"unknown task status {status:?} (use pending|active|done|dropped|blocked)"
1253					),
1254					at,
1255				});
1256			}
1257			let mut task = TodoTask::new();
1258			replay_props!(task, props);
1259			if !label.is_empty() {
1260				task = task.label(label);
1261			}
1262			for part in parts {
1263				match part {
1264					Parsed::Task { task: child, .. } => task = task.task(child),
1265					other => return Err(parent_error(other.name(), "task", at)),
1266				}
1267			}
1268			Ok(Parsed::Task { task, at })
1269		},
1270		"field" => {
1271			let label = take_label(&mut parts);
1272			let children = cached_children(parts, "field")?;
1273			let mut field = Field::new();
1274			replay_props!(field, props);
1275			if let Some(label) = label {
1276				field = field.label(label);
1277			}
1278			if !children.is_empty() {
1279				field = field.child(children);
1280			}
1281			Ok(Parsed::Field { field, at })
1282		},
1283		"step" => {
1284			let title = props.title().cloned().unwrap_or_else(|| Str::new("step"));
1285			let children = cached_children(parts, "step")?;
1286			Ok(Parsed::Step { title, children, at })
1287		},
1288		"select" => {
1289			let mut select = Select::new();
1290			replay_props!(select, props);
1291			*select.props_mut() = props;
1292			for part in parts {
1293				match part {
1294					Parsed::Option { option, .. } => select = select.option(option),
1295					other => return Err(parent_error(other.name(), "select", at)),
1296				}
1297			}
1298			Ok(Parsed::Cached {
1299				cached: Box::new(Cached::new(Box::new(select))),
1300				text: None,
1301				at,
1302				implicit: false,
1303			})
1304		},
1305		"status" => {
1306			let mut status = Status::new();
1307			replay_props!(status, props);
1308			*status.props_mut() = props;
1309			for part in parts {
1310				match part {
1311					Parsed::Segment { segment, .. } => status = status.segment(segment),
1312					other => return Err(parent_error(other.name(), "status", at)),
1313				}
1314			}
1315			Ok(Parsed::Cached {
1316				cached: Box::new(Cached::new(Box::new(status))),
1317				text: None,
1318				at,
1319				implicit: false,
1320			})
1321		},
1322		"editor" => {
1323			let mut editor = EditorPane::new();
1324			replay_props!(editor, props);
1325			let mut has_input = false;
1326			let mut has_status = false;
1327			for part in parts {
1328				let Parsed::Cached { cached, at: child_at, implicit, .. } = part else {
1329					return Err(ParseError {
1330						message: "<editor> takes at most one input child and one <status>".into(),
1331						at,
1332					});
1333				};
1334				if implicit {
1335					return Err(ParseError {
1336						message: "<editor> takes at most one input child and one <status>".into(),
1337						at:      child_at,
1338					});
1339				}
1340				if cached.comp().is::<Status>() {
1341					if has_status {
1342						return Err(ParseError {
1343							message: "<editor> takes at most one input child and one <status>".into(),
1344							at:      child_at,
1345						});
1346					}
1347					editor = editor.status(cached.into_comp());
1348					has_status = true;
1349				} else {
1350					if has_input {
1351						return Err(ParseError {
1352							message: "<editor> takes at most one input child and one <status>".into(),
1353							at:      child_at,
1354						});
1355					}
1356					editor = editor.input(cached.into_comp());
1357					has_input = true;
1358				}
1359			}
1360			Ok(Parsed::Cached {
1361				cached: Box::new(Cached::new(boxed_component(editor, props))),
1362				text: None,
1363				at,
1364				implicit: false,
1365			})
1366		},
1367		"tabs" => {
1368			let mut tabs = Tabs::new();
1369			replay_props!(tabs, props);
1370			*tabs.props_mut() = props;
1371			for part in parts {
1372				match part {
1373					Parsed::Tab { title, children, .. } => tabs = tabs.pane(title, children),
1374					other => return Err(parent_error(other.name(), "tabs", at)),
1375				}
1376			}
1377			Ok(Parsed::Cached {
1378				cached: Box::new(Cached::new(Box::new(tabs))),
1379				text: None,
1380				at,
1381				implicit: false,
1382			})
1383		},
1384		"tree" => {
1385			let mut tree = Tree::new();
1386			replay_props!(tree, props);
1387			*tree.props_mut() = props;
1388			for part in parts {
1389				match part {
1390					Parsed::TreeItem { node, .. } => tree = tree.node(node),
1391					other => return Err(parent_error(other.name(), "tree", at)),
1392				}
1393			}
1394			Ok(Parsed::Cached {
1395				cached: Box::new(Cached::new(Box::new(tree))),
1396				text: None,
1397				at,
1398				implicit: false,
1399			})
1400		},
1401		"todo" => {
1402			let mut todo = Todo::new();
1403			replay_props!(todo, props);
1404			*todo.props_mut() = props;
1405			for part in parts {
1406				match part {
1407					Parsed::Task { task, .. } => todo = todo.task(task),
1408					other => return Err(parent_error(other.name(), "todo", at)),
1409				}
1410			}
1411			Ok(Parsed::Cached {
1412				cached: Box::new(Cached::new(Box::new(todo))),
1413				text: None,
1414				at,
1415				implicit: false,
1416			})
1417		},
1418		"form" => {
1419			let mut form = Form::new();
1420			replay_props!(form, props);
1421			*form.props_mut() = props;
1422			for part in parts {
1423				match part {
1424					Parsed::Field { field, .. } => form = form.field(field),
1425					other => return Err(parent_error(other.name(), "form", at)),
1426				}
1427			}
1428			Ok(Parsed::Cached {
1429				cached: Box::new(Cached::new(Box::new(form))),
1430				text: None,
1431				at,
1432				implicit: false,
1433			})
1434		},
1435		"wizard" => {
1436			let mut wizard = Wizard::new();
1437			replay_props!(wizard, props);
1438			*wizard.props_mut() = props;
1439			for part in parts {
1440				match part {
1441					Parsed::Step { title, children, .. } => wizard = wizard.step(title, children),
1442					other => return Err(parent_error(other.name(), "wizard", at)),
1443				}
1444			}
1445			Ok(Parsed::Cached {
1446				cached: Box::new(Cached::new(Box::new(wizard))),
1447				text: None,
1448				at,
1449				implicit: false,
1450			})
1451		},
1452		_ => {
1453			let children = cached_children(parts, tag)?;
1454			let text = matches!(tag, "text" | "pre" | "md" | "latex").then(|| body.clone());
1455			let component = if is_catalog_tag(tag) {
1456				build(tag, props, children, &body).expect("catalog tag has a component")
1457			} else {
1458				let mut custom = CustomElement::new(tag).child(children);
1459				replay_props!(custom, props);
1460				boxed_component(custom, props)
1461			};
1462			Ok(Parsed::Cached { cached: Box::new(Cached::new(component)), text, at, implicit: false })
1463		},
1464	}
1465}
1466
1467fn take_label(parts: &mut Vec<Parsed>) -> Option<Str> {
1468	let index = parts
1469		.iter()
1470		.position(|part| matches!(part, Parsed::Cached { text: Some(_), .. }))?;
1471	match parts.remove(index) {
1472		Parsed::Cached { text: Some(text), .. } => Some(text),
1473		_ => unreachable!(),
1474	}
1475}
1476fn markdown_with_parts(
1477	props: Props,
1478	text: Str,
1479	children: Vec<Cached>,
1480	at: usize,
1481	implicit: bool,
1482) -> Parsed {
1483	let metadata = text.clone();
1484	let component = build("md", props, children, &text).expect("markdown is a catalog component");
1485	Parsed::Cached { cached: Box::new(Cached::new(component)), text: Some(metadata), at, implicit }
1486}
1487
1488fn markdown_part(text: Str, props: Props) -> Cached {
1489	let visible = !text.is_empty();
1490	let Parsed::Cached { cached, .. } = markdown_with_parts(props, text, Vec::new(), 0, false)
1491	else {
1492		unreachable!();
1493	};
1494	let mut cached = *cached;
1495	cached.visible = visible;
1496	cached
1497}
1498
1499fn markdown_parsed(text: Str, props: Props, at: usize) -> Parsed {
1500	markdown_with_parts(props, text, Vec::new(), at, true)
1501}
1502
1503fn apply_attrs(
1504	attrs: &str,
1505	at: usize,
1506	source: &Str,
1507	ctx: &UiContext,
1508	inherited: &Props,
1509) -> Result<Props, ParseError> {
1510	let mut props = inherited.clone();
1511	for (key, value) in (AttrIter { rest: attrs }) {
1512		if matches!(key, "gradient" | "dir") {
1513			return Err(ParseError {
1514				message: format!("{key} was replaced by fg=/bg= and angle="),
1515				at,
1516			});
1517		}
1518		if value.is_none() && ctx.theme.token(key).is_some() {
1519			props
1520				.try_set(Prop::Fg, PropValue::Token(source.slice_ref(key)))
1521				.map_err(|error| bad(key, &error.value, at))?;
1522		} else if let Some(prop) = Props::prop_of(key) {
1523			let value = match (prop, value) {
1524				(Prop::Title, Some(value)) => PropValue::Str(resolve_icons(ctx.charset, source, value)),
1525				(Prop::Gap | Prop::Grow, None) => PropValue::Str(Str::new("1")),
1526				(_, Some(value)) => PropValue::Str(source.slice_ref(value)),
1527				(_, None) => PropValue::Bool(true),
1528			};
1529			props
1530				.try_set(prop, value)
1531				.map_err(|error| bad(key, &error.value, at))?;
1532		} else {
1533			let value =
1534				value.map_or(PropValue::Bool(true), |value| PropValue::Str(source.slice_ref(value)));
1535			props.set_custom(source.slice_ref(key), value);
1536		}
1537	}
1538	Ok(props)
1539}
1540
1541fn bad(key: &str, value: &str, at: usize) -> ParseError {
1542	ParseError { message: format!("bad value {value:?} for attribute {key}"), at }
1543}
1544
1545/// Zero-alloc attribute scanner: `key`, `key=value`, `key="quoted value"`.
1546struct AttrIter<'a> {
1547	rest: &'a str,
1548}
1549
1550impl<'a> Iterator for AttrIter<'a> {
1551	type Item = (&'a str, Option<&'a str>);
1552
1553	fn next(&mut self) -> Option<Self::Item> {
1554		self.rest = self.rest.trim_start();
1555		if self.rest.is_empty() {
1556			return None;
1557		}
1558		let key_end = self.rest.find(|c: char| c == '=' || c.is_whitespace());
1559		let (key, after) = match key_end {
1560			Some(p) => (&self.rest[..p], &self.rest[p..]),
1561			None => (self.rest, ""),
1562		};
1563		if let Some(after_eq) = after.strip_prefix('=') {
1564			// `key="a b"`, `key='a b'`, and bare `key=a` are equivalent
1565			for quote in ['"', '\''] {
1566				if let Some(quoted) = after_eq.strip_prefix(quote) {
1567					let end = quoted.find(quote).unwrap_or(quoted.len());
1568					self.rest = quoted.get(end + 1..).unwrap_or("");
1569					return Some((key, Some(&quoted[..end])));
1570				}
1571			}
1572			let end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
1573			self.rest = &after_eq[end..];
1574			return Some((key, Some(&after_eq[..end])));
1575		}
1576		self.rest = after;
1577		Some((key, None))
1578	}
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583	use super::*;
1584	use crate::frame::{Color, Style};
1585
1586	fn child(node: &Cached, index: usize) -> &Cached {
1587		&node.comp().children()[index]
1588	}
1589
1590	#[test]
1591	fn well_known_bad_values_are_parse_errors() {
1592		let ctx = UiContext::default();
1593		assert!(parse(&Str::new("<text fg=nosuch>x</text>"), &ctx).is_err());
1594		assert!(parse(&Str::new("<text fg=\"rgb(300,0)\">x</text>"), &ctx).is_err());
1595		assert!(parse(&Str::new("<text fg=💥>x</text>"), &ctx).is_err());
1596		assert!(parse(&Str::new("<text fg=\"rgb💥(1,2,3)\">x</text>"), &ctx).is_err());
1597		assert!(parse(&Str::new("<text fg=#héx>x</text>"), &ctx).is_err());
1598	}
1599
1600	#[test]
1601	fn chrome_attributes_parse_with_aliases_and_flags() {
1602		let ctx = UiContext::default();
1603		let root = parse(
1604			&Str::new(
1605				"<col><box border=dash on=navy edge=red><text accent reverse strike truncate \
1606				 mystery>x</text></box><spacer/></col>",
1607			),
1608			&ctx,
1609		)
1610		.unwrap();
1611		let col = child(&root, 0);
1612		let boxed = child(col, 0);
1613		assert_eq!(boxed.comp().props().border(), Some(Border::Dash));
1614		assert_eq!(boxed.comp().props().style(&ctx.theme).background_color(), Color::Rgb(0, 0, 0x80));
1615		assert_eq!(boxed.comp().props().edge(&ctx.theme), Some(Color::Rgb(0xff, 0, 0)));
1616		assert_eq!(boxed.comp().props().pad().1, 1);
1617		let text = child(boxed, 0).comp().props();
1618		assert_eq!(
1619			text.style(&ctx.theme),
1620			Style::new().fg(ctx.theme.accent).reverse().strikethrough()
1621		);
1622		assert!(text.flag(Prop::Truncate));
1623		assert_eq!(text.custom("mystery"), Some(&PropValue::Bool(true)));
1624		assert_eq!(child(col, 1).comp().props().grow(), Some(1.0));
1625
1626		let overrides =
1627			parse(&Str::new("<col><box pad='2 3'></box><spacer grow=2/></col>"), &ctx).unwrap();
1628		let col = child(&overrides, 0);
1629		assert_eq!(child(col, 0).comp().props().pad(), (2, 3));
1630		assert_eq!(child(col, 1).comp().props().grow(), Some(2.0));
1631	}
1632
1633	#[test]
1634	fn attribute_quoting_styles_are_equivalent() {
1635		let ctx = UiContext::default();
1636		for src in [
1637			"<box title=b><text>x</text></box>",
1638			"<box title='b'><text>x</text></box>",
1639			"<box title=\"b\"><text>x</text></box>",
1640		] {
1641			let root = parse(&Str::new(src), &ctx).unwrap();
1642			assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("b"));
1643		}
1644		let root =
1645			parse(&Str::new("<box title='say \"hi\" now'><text>x</text></box>"), &ctx).unwrap();
1646		assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("say \"hi\" now"));
1647	}
1648
1649	#[test]
1650	fn title_resolves_ico_tags_through_the_charset() {
1651		let unicode = UiContext::default();
1652		let src = Str::new("<box title=\"<ico:folder/> Files\"><text>x</text></box>");
1653		let root = parse(&src, &unicode).unwrap();
1654		assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 Files"));
1655
1656		let ascii = UiContext { charset: Charset::Ascii, ..UiContext::default() };
1657		let root = parse(&src, &ascii).unwrap();
1658		assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("[D] Files"));
1659
1660		let src = Str::new("<hr title=\"<ico:icon.folder/> <ico:nope/>\"/>");
1661		let root = parse(&src, &unicode).unwrap();
1662		assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 nope"));
1663	}
1664
1665	#[test]
1666	fn styles_cascade_to_children() {
1667		let ctx = UiContext::default();
1668		let root = parse(
1669			&Str::new(
1670				"<col fg=#0000ff bold><text>a</text><text fg=#ff0000>b</text><box \
1671				 bg=#00ff00><text>c</text></box></col>",
1672			),
1673			&ctx,
1674		)
1675		.unwrap();
1676		let col = child(&root, 0);
1677		let blue = Color::Rgb(0, 0, 0xff);
1678		assert_eq!(child(col, 0).comp().props().style(&ctx.theme), Style::new().fg(blue).bold());
1679		assert_eq!(
1680			child(col, 1).comp().props().style(&ctx.theme),
1681			Style::new().fg(Color::Rgb(0xff, 0, 0)).bold()
1682		);
1683		let nested = child(child(col, 2), 0).comp().props().style(&ctx.theme);
1684		assert_eq!(nested, Style::new().fg(blue).bold());
1685		assert_eq!(nested.background_color(), Color::Default);
1686	}
1687
1688	#[test]
1689	fn gradients_live_in_property_values() {
1690		let ctx = UiContext::default();
1691		let root = parse(
1692			&Str::new(
1693				r##"<box bg="#000000..#ffffff" angle=90><pre fg="accent..info" angle=45>  ██
1694 █</pre></box>"##,
1695			),
1696			&ctx,
1697		)
1698		.unwrap();
1699		let boxed = child(&root, 0);
1700		assert_eq!(
1701			boxed.comp().props().get(Prop::Bg),
1702			Some(&PropValue::Gradient(Str::new("#000000..#ffffff")))
1703		);
1704		assert_eq!(boxed.comp().props().angle(), 90);
1705		let pre = child(boxed, 0);
1706		assert_eq!(
1707			pre.comp().props().get(Prop::Fg),
1708			Some(&PropValue::Gradient(Str::new("accent..info")))
1709		);
1710		assert_eq!(pre.comp().props().angle(), 45);
1711		for source in ["<pre gradient=\"accent..info\">x</pre>", "<pre dir=h>x</pre>"] {
1712			assert!(parse(&Str::new(source), &ctx).is_err(), "{source}");
1713		}
1714	}
1715
1716	#[test]
1717	fn custom_elements_require_a_complete_tag_pair() {
1718		let ctx = UiContext::default();
1719		let root = parse(&Str::new("<panel mystery><text>x</text></panel>"), &ctx).unwrap();
1720		let panel = child(&root, 0);
1721		assert_eq!(panel.comp().props().custom("mystery"), Some(&PropValue::Bool(true)));
1722		assert_eq!(panel.comp().children().len(), 1);
1723
1724		let literal = parse(&Str::new("before <panel> after"), &ctx).unwrap();
1725		assert_eq!(literal.comp().children().len(), 1);
1726	}
1727
1728	#[test]
1729	fn markdown_html_stays_literal_but_line_start_custom_elements_embed() {
1730		let ctx = UiContext::default();
1731		let html = parse(&Str::new("<md>before <span>inside</span> after</md>"), &ctx).unwrap();
1732		assert!(child(&html, 0).comp().children().is_empty());
1733
1734		let custom = parse(&Str::new("<md>before\n<panel/>\nafter</md>"), &ctx).unwrap();
1735		assert_eq!(child(&custom, 0).comp().children().len(), 2);
1736	}
1737}