use omp_core::{Str, StrMut};
use crate::{
component::{Cached, Component},
components::{
Boxed, Button, Callout, Col, CustomElement, EditorPane, Field, Form, Hr, Icon, Img, Input,
Latex, Markdown, Pre, Progress, Radio, Row, Scroll, Segment, Select, SelectOption, Spacer,
Spinner, Status, Table, TableCell, TableRow, Tabs, TaskStatus, TextLeaf, Todo, TodoTask,
Tree, TreeNode, Wizard,
},
context::{Charset, UiContext},
props::{Prop, PropValue, Props},
};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Border {
#[default]
Square,
Dash,
Round,
Heavy,
Double,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Dim {
Cells(u16),
Pct(u8),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Align {
#[default]
Start,
Center,
End,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Justify {
#[default]
Start,
Center,
End,
Between,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Truncate {
#[default]
End,
Start,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VAlign {
Start,
Center,
End,
Stretch,
}
#[derive(Debug)]
pub struct ParseError {
pub message: String,
pub at: usize,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "markup error at byte {}: {}", self.at, self.message)
}
}
impl std::error::Error for ParseError {}
pub fn parse(source: &Str, ctx: &UiContext) -> Result<Cached, ParseError> {
let mut parser = Parser { source, src: source, ctx, fragment: false };
let (parts, _) = parser.parse_children(0, None, false, 0, "col", &Props::new())?;
let children = cached_children(parts, "col")?;
let root = build("col", Props::new(), children, &Str::default())
.expect("the root col is a catalog component");
Ok(Cached::new(root))
}
pub fn parse_md_fragment_inheriting(
text: &Str,
ctx: &UiContext,
host: &Props,
) -> Result<Vec<Cached>, ParseError> {
if text.is_empty() {
return Ok(Vec::new());
}
let inherited = child_props(host);
let mut parser = Parser { source: text, src: text, ctx, fragment: true };
let (first, mut children, _) = parser.scan_md(0, 0, &inherited, false)?;
children.insert(0, markdown_part(first, inherited));
Ok(children)
}
#[allow(
clippy::large_enum_variant,
reason = "parser nodes move once into their final owners; boxing the larger variants would add \
one allocation per parsed node"
)]
enum Parsed {
Cached { cached: Box<Cached>, text: Option<Str>, at: usize, implicit: bool },
Option { option: SelectOption, at: usize },
Segment { segment: Segment, at: usize },
Tab { title: Str, children: Vec<Cached>, at: usize },
TreeItem { node: TreeNode, at: usize },
Task { task: TodoTask, at: usize },
Field { field: Field, at: usize },
Step { title: Str, children: Vec<Cached>, at: usize },
TableRow { row: Box<TableRow>, at: usize },
Cell { cell: TableCell, at: usize },
}
impl Parsed {
fn into_cached(self, parent: &str) -> Result<Cached, ParseError> {
match self {
Self::Cached { cached, .. } => Ok(*cached),
Self::Option { at, .. } => Err(parent_error("option", parent, at)),
Self::Segment { at, .. } => Err(parent_error("segment", parent, at)),
Self::Tab { at, .. } => Err(parent_error("tab", parent, at)),
Self::TreeItem { at, .. } => Err(parent_error("node", parent, at)),
Self::Task { at, .. } => Err(parent_error("task", parent, at)),
Self::Field { at, .. } => Err(parent_error("field", parent, at)),
Self::Step { at, .. } => Err(parent_error("step", parent, at)),
Self::TableRow { at, .. } => Err(parent_error("tr", parent, at)),
Self::Cell { at, .. } => Err(parent_error("td", parent, at)),
}
}
const fn name(&self) -> &'static str {
match self {
Self::Cached { .. } => "content",
Self::Option { .. } => "option",
Self::Segment { .. } => "segment",
Self::Tab { .. } => "tab",
Self::TreeItem { .. } => "node",
Self::Task { .. } => "task",
Self::Field { .. } => "field",
Self::Step { .. } => "step",
Self::TableRow { .. } => "tr",
Self::Cell { .. } => "td",
}
}
}
fn parent_error(tag: &str, parent: &str, at: usize) -> ParseError {
ParseError { message: format!("<{tag}> is not allowed directly inside <{parent}>"), at }
}
fn cached_children(parts: Vec<Parsed>, parent: &str) -> Result<Vec<Cached>, ParseError> {
parts
.into_iter()
.map(|part| part.into_cached(parent))
.collect()
}
struct Parser<'a> {
source: &'a Str,
src: &'a str,
ctx: &'a UiContext,
fragment: bool,
}
impl Parser<'_> {
fn parse_children(
&mut self,
body_start: usize,
closing: Option<&str>,
restricted: bool,
indent: usize,
parent_tag: &str,
parent_props: &Props,
) -> Result<(Vec<Parsed>, usize), ParseError> {
let mut parts = Vec::new();
let mut segment_start = body_start;
let mut at = body_start;
let mut fence = FenceScan::segment(indent);
while at < self.src.len() {
let Some(offset) = self.src[at..].find('<') else {
break;
};
fence.consume(&self.src[at..at + offset]);
at += offset;
if self.src[at..].starts_with("<!--") {
let skip = self.src[at + 4..]
.find("-->")
.map_or(1, |end| end + 4 + "-->".len());
fence.skip(&self.src[at..at + skip]);
at += skip;
continue;
}
let name = tag_name(&self.src[at + 1..]);
let escaped = self.src[segment_start..at]
.bytes()
.rev()
.take_while(|&byte| byte == b'\\')
.count() % 2
== 1;
let literal = escaped
|| fence.in_code()
|| fence.in_code_span(&self.src[at..])
|| in_math_span(&self.src[segment_start..], at - segment_start);
if literal || name.is_none() {
fence.consume(&self.src[at..=at]);
at += 1;
continue;
}
let name = name.unwrap_or_default();
let Some(close) = tag_close(&self.src[at + 1..]).map(|end| end + at + 1) else {
break;
};
let is_closing = self.src[at + 1..].starts_with('/');
let raw = &self.src[at + 1..close];
let self_closing = raw.ends_with('/');
let catalog = is_catalog_tag(name);
let custom = !catalog
&& !is_markdown_html_tag(name)
&& if is_closing {
closing == Some(name)
} else {
self_closing || has_matching_close(&self.src[close + 1..], name)
};
if !catalog && !custom {
fence.consume(&self.src[at..=at]);
at += 1;
continue;
}
if is_closing {
if closing != Some(name) {
return Err(ParseError {
message: format!(
"closing </{name}> does not match open <{}>",
closing.unwrap_or("nothing")
),
at,
});
}
self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
return Ok((parts, close + 1));
}
self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
let (part, next) = self.parse_element(at, close, restricted, parent_props)?;
parts.push(part);
at = next;
segment_start = at;
fence = FenceScan::segment(indent);
}
if closing.is_some() {
return Err(ParseError {
message: format!("unclosed <{}> tag", closing.unwrap_or_default()),
at: self.src.len(),
});
}
self.add_text(&mut parts, parent_tag, parent_props, segment_start, self.src.len(), indent);
Ok((parts, self.src.len()))
}
fn parse_element(
&mut self,
at: usize,
close: usize,
restricted: bool,
inherited: &Props,
) -> Result<(Parsed, usize), ParseError> {
let raw = &self.src[at + 1..close];
let self_closing = raw.ends_with('/');
let raw = raw.strip_suffix('/').unwrap_or(raw);
let (name, attrs) = raw.split_once(char::is_whitespace).unwrap_or((raw, ""));
let indent = leading_spaces(line_of(self.src, at));
if restricted && is_interactive_tag(name) {
return Err(ParseError {
message: format!("interactive tag <{name}> is not allowed inside <md>"),
at,
});
}
let mut props = apply_attrs(attrs, at, self.source, self.ctx, inherited)?;
let tag = self.source.slice_ref(name);
let name = tag.as_str();
if self.fragment && (props.get(Prop::Id).is_some() || props.get(Prop::When).is_some()) {
return Err(ParseError {
message: "id= and when= are not allowed in dynamic Markdown".into(),
at,
});
}
if name == "box" {
if props.get(Prop::Border).is_none() {
props
.try_set(Prop::Border, PropValue::Border(Border::Square))
.unwrap();
}
if props.get(Prop::PadX).is_none() {
props.try_set(Prop::PadX, PropValue::U16(1)).unwrap();
}
} else if name == "spacer" && props.get(Prop::Grow).is_none() {
props.try_set(Prop::Grow, PropValue::F32(1.0)).unwrap();
}
let body_start = close + 1;
if self_closing {
return finish_element(name, props, Vec::new(), Str::default(), at)
.map(|part| (part, body_start));
}
if matches!(name, "pre" | "latex" | "callout") {
let closer = match name {
"pre" => "</pre>",
"latex" => "</latex>",
"callout" => "</callout>",
_ => unreachable!(),
};
let end = self.src[body_start..]
.find(closer)
.map_or(self.src.len(), |offset| body_start + offset);
let trim: &[_] = if name == "pre" {
&['\n', '\r']
} else {
&['\n']
};
let body = self.src[body_start..end].trim_matches(trim);
let body = self.source.slice_ref(body);
let part = finish_element(name, props, Vec::new(), body, at)?;
return Ok((part, (end + closer.len()).min(self.src.len())));
}
if matches!(name, "text" | "button" | "icon") {
let closer = match name {
"text" => "</text>",
"button" => "</button>",
"icon" => "</icon>",
_ => unreachable!(),
};
let end = self.src[body_start..]
.find(closer)
.map(|offset| body_start + offset)
.ok_or_else(|| ParseError {
message: format!("unclosed <{name}> tag"),
at: self.src.len(),
})?;
let body = self
.source
.slice_ref(self.src[body_start..end].trim_matches(['\n', '\r']));
let part = finish_element(name, props, Vec::new(), body, at)?;
return Ok((part, end + closer.len()));
}
if name == "md" {
return self.parse_md(body_start, indent, props, true);
}
if is_leaf_tag(name) {
return finish_element(name, props, Vec::new(), Str::default(), at)
.map(|part| (part, body_start));
}
let child_props = child_props(&props);
let (parts, end) =
self.parse_children(body_start, Some(name), restricted, indent, name, &child_props)?;
let part = finish_element(name, props, parts, Str::default(), at)?;
Ok((part, end))
}
fn parse_md(
&mut self,
body_start: usize,
indent: usize,
props: Props,
require_close: bool,
) -> Result<(Parsed, usize), ParseError> {
let (text, children, end) = self.scan_md(body_start, indent, &props, require_close)?;
Ok((markdown_with_parts(props, text, children, body_start, false), end))
}
fn scan_md(
&mut self,
body_start: usize,
indent: usize,
props: &Props,
require_close: bool,
) -> Result<(Str, Vec<Cached>, usize), ParseError> {
let mut segment_start = body_start;
let mut first = true;
let mut first_text = Str::default();
let mut embedded = Vec::new();
let child_props = child_props(props);
loop {
let event = self.next_md_event(segment_start, indent, require_close)?;
let (end, element) = match event {
MdEvent::Close(at) | MdEvent::End(at) => (at, None),
MdEvent::Element(at, close) => (at, Some((at, close))),
};
let body = self.src[segment_start..end].trim_matches('\n');
let text = dedent(self.source, body, indent);
if first {
first_text = text;
first = false;
} else {
embedded.push(markdown_part(text, child_props.clone()));
}
let Some((at, close)) = element else {
let next = if require_close {
end + "</md>".len()
} else {
end
};
return Ok((first_text, embedded, next));
};
let (part, next) = self.parse_element(at, close, true, &child_props)?;
embedded.push(part.into_cached("md")?);
segment_start = next;
}
}
fn next_md_event(
&self,
mut at: usize,
indent: usize,
require_close: bool,
) -> Result<MdEvent, ParseError> {
let mut fence: Option<(u8, usize)> = None;
while at < self.src.len() {
let line_end = self.src[at..].find('\n').map_or(self.src.len(), |p| p + at);
let line = self.src[at..line_end]
.strip_suffix('\r')
.unwrap_or_else(|| &self.src[at..line_end]);
let trimmed = line.trim_start();
let prefix = &line[..line.len() - trimmed.len()];
let spaces = prefix.as_bytes().iter().take_while(|&&b| b == b' ').count();
let indented_code = spaces >= indent + 4 || prefix.contains('\t');
if let Some((marker, length)) = fence {
let run = trimmed
.as_bytes()
.iter()
.take_while(|&&b| b == marker)
.count();
if run >= length {
fence = None;
if let Some(pos) = trimmed[run..].find("</md>") {
let close_at = at + (line.len() - trimmed.len()) + run + pos;
if require_close {
return Ok(MdEvent::Close(close_at));
}
return Err(stray_md_close(close_at));
}
}
} else {
let marker = trimmed.as_bytes().first().copied();
let run =
marker.map_or(0, |m| trimmed.as_bytes().iter().take_while(|&&b| b == m).count());
if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented_code {
fence = Some((marker.unwrap_or_default(), run));
} else {
let tag_at = at + (line.len() - trimmed.len());
if !indented_code && let Some((name, close)) = line_tag(trimmed, tag_at) {
if is_md_block_tag(name) || is_custom_tag_at(self.src, name, tag_at, close) {
return Ok(MdEvent::Element(tag_at, close));
}
if is_interactive_tag(name) {
return Err(ParseError {
message: format!("interactive tag <{name}> is not allowed inside <md>"),
at: tag_at,
});
}
}
if let Some(close) = line.find("</md>") {
let close_at = at + close;
if require_close {
return Ok(MdEvent::Close(close_at));
}
return Err(stray_md_close(close_at));
}
}
}
at = if line_end < self.src.len() {
line_end + 1
} else {
line_end
};
}
if require_close {
Err(ParseError { message: "unclosed <md> tag".into(), at: self.src.len() })
} else {
Ok(MdEvent::End(self.src.len()))
}
}
fn add_text(
&self,
parts: &mut Vec<Parsed>,
parent_tag: &str,
parent_props: &Props,
start: usize,
end: usize,
indent: usize,
) {
let raw = &self.src[start..end];
if raw.trim().is_empty() {
return;
}
let props = parent_props.clone();
if parent_tag == "row" {
let mut table_start: Option<usize> = None;
let mut offset = 0;
for line in raw.split_inclusive('\n') {
let content = line.trim();
let at = start + offset;
offset += line.len();
if content.starts_with('|') {
table_start.get_or_insert(at);
continue;
}
if let Some(from) = table_start.take() {
let chunk = self.src[from..at].trim_matches(['\n', '\r']);
if !chunk.trim().is_empty() {
let text = dedent(self.source, chunk, indent);
parts.push(markdown_parsed(text, props.clone(), from));
}
}
if !content.is_empty() {
let text = self.source.slice_ref(content);
parts.push(markdown_parsed(text, props.clone(), at));
}
}
if let Some(from) = table_start {
let chunk = self.src[from..end].trim_matches(['\n', '\r']);
if !chunk.trim().is_empty() {
let text = dedent(self.source, chunk, indent);
parts.push(markdown_parsed(text, props, from));
}
}
} else {
let text = dedent(self.source, raw.trim_matches(['\n', '\r']), indent);
parts.push(markdown_parsed(text, props, start));
}
}
}
fn dedent(source: &Str, body: &str, indent: usize) -> Str {
if indent == 0 || !body.lines().any(|line| line.starts_with(' ')) {
return source.slice_ref(body);
}
let mut out = StrMut::with_capacity(body.len());
for (index, line) in body.split('\n').enumerate() {
if index > 0 {
out.push_str("\n");
}
out.push_str(&line[leading_spaces(line).min(indent)..]);
}
out.freeze()
}
fn leading_spaces(line: &str) -> usize {
line.bytes().take_while(|byte| *byte == b' ').count()
}
fn line_of(src: &str, at: usize) -> &str {
let start = src[..at].rfind('\n').map_or(0, |newline| newline + 1);
&src[start..at]
}
struct FenceScan {
fence: Option<(u8, usize)>,
code: Option<usize>,
indented: bool,
indent: usize,
at_line_start: bool,
}
impl FenceScan {
const fn segment(indent: usize) -> Self {
Self { fence: None, code: None, indented: false, indent, at_line_start: true }
}
const fn in_code(&self) -> bool {
self.fence.is_some() || self.indented
}
fn in_code_span(&self, tail: &str) -> bool {
let Some(length) = self.code else {
return false;
};
let bytes = tail.as_bytes();
let mut at = 0;
while at < bytes.len() {
if bytes[at] != b'`' {
at += 1;
continue;
}
let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
if run == length {
return true;
}
at += run;
}
false
}
fn skip(&mut self, text: &str) {
for piece in text.split_inclusive('\n') {
self.at_line_start = piece.ends_with('\n');
if self.at_line_start {
self.indented = false;
}
}
}
fn consume(&mut self, run: &str) {
for piece in run.split_inclusive('\n') {
let line = piece.trim_end_matches(['\n', '\r']);
if self.at_line_start {
self.indented = leading_spaces(line) >= self.indent + 4;
}
let fenced = self.at_line_start && self.fence_marker(line);
if !fenced && self.fence.is_none() {
self.code_spans(line);
}
self.at_line_start = piece.ends_with('\n');
if self.at_line_start {
self.indented = false;
}
}
}
fn fence_marker(&mut self, line: &str) -> bool {
let trimmed = line.trim_start_matches(' ');
if line.len().saturating_sub(trimmed.len()) > 3 {
return false;
}
let Some(marker) = trimmed.as_bytes().first().copied() else {
return false;
};
let run = trimmed.bytes().take_while(|&byte| byte == marker).count();
if let Some((open, length)) = self.fence {
let closes = marker == open && run >= length && trimmed[run..].trim().is_empty();
if closes {
self.fence = None;
}
return closes;
}
let opens = matches!(marker, b'`' | b'~') && run >= 3;
if opens {
self.fence = Some((marker, run));
self.code = None;
}
opens
}
fn code_spans(&mut self, line: &str) {
let bytes = line.as_bytes();
let mut at = 0;
while at < bytes.len() {
if bytes[at] != b'`' {
at += 1;
continue;
}
let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
match self.code {
Some(length) if length == run => self.code = None,
Some(_) => {},
None => self.code = Some(run),
}
at += run;
}
}
}
fn in_math_span(text: &str, offset: usize) -> bool {
let mut at = 0;
while at < offset {
let Some(next) = text[at..].find(['$', '\\']) else {
return false;
};
let start = at + next;
if start >= offset {
return false;
}
match crate::markdown::math_span(&text[start..]) {
Some((_, consumed)) if offset < start + consumed => return true,
Some((_, consumed)) => at = start + consumed,
None => at = start + 1,
}
}
false
}
fn tag_name(after: &str) -> Option<&str> {
let after = after.strip_prefix('/').unwrap_or(after);
let end = after
.find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
.unwrap_or(after.len());
let name = &after[..end];
let rest = &after[end..];
let delimited =
rest.starts_with('>') || rest.starts_with("/>") || rest.starts_with(char::is_whitespace);
(delimited && name.starts_with(|character: char| character.is_ascii_alphabetic()))
.then_some(name)
}
const fn tag_close(after: &str) -> Option<usize> {
let bytes = after.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'>' => return Some(index),
quote @ (b'"' | b'\'') => {
index += 1;
while index < bytes.len() && bytes[index] != quote {
index += 1;
}
if index == bytes.len() {
return None;
}
index += 1;
},
_ => index += 1,
}
}
None
}
pub fn ico_tag(text: &str) -> Option<(&str, usize)> {
let rest = text.strip_prefix("<ico:")?;
let end = rest
.find(|character: char| {
!(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
})
.unwrap_or(rest.len());
let name = &rest[..end];
let tail = rest[end..].trim_start_matches(' ');
let tail = tail.strip_prefix('/').unwrap_or(tail);
let tail = tail.strip_prefix('>')?;
(!name.is_empty()).then(|| (name, text.len() - tail.len()))
}
fn resolve_icons(charset: Charset, source: &Str, value: &str) -> Str {
if !value.contains("<ico:") {
return source.slice_ref(value);
}
let mut resolved = StrMut::new("");
let mut rest = value;
while let Some(at) = rest.find("<ico:") {
if let Some((name, consumed)) = ico_tag(&rest[at..]) {
resolved.push_str(&rest[..at]);
resolved.push_str(charset.icon_named(name).unwrap_or(name));
rest = &rest[at + consumed..];
} else {
resolved.push_str(&rest[..at + "<ico:".len()]);
rest = &rest[at + "<ico:".len()..];
}
}
resolved.push_str(rest);
resolved.freeze()
}
enum MdEvent {
Close(usize),
Element(usize, usize),
End(usize),
}
fn line_tag(line: &str, at: usize) -> Option<(&str, usize)> {
let raw = line.strip_prefix('<')?;
let close = tag_close(raw)?;
let tag = raw[..close].strip_suffix('/').unwrap_or(&raw[..close]);
let name = tag
.split_once(char::is_whitespace)
.map_or(tag, |(name, _)| name);
Some((name, at + close + 1))
}
fn is_md_block_tag(name: &str) -> bool {
is_catalog_tag(name) && !is_interactive_tag(name)
}
fn is_markdown_html_tag(name: &str) -> bool {
matches!(name, "br" | "p" | "span" | "code" | "li" | "ul" | "ol" | "blockquote")
}
fn is_catalog_tag(name: &str) -> bool {
matches!(
name,
"col"
| "row"
| "box"
| "text"
| "pre"
| "md" | "latex"
| "hr" | "spacer"
| "select"
| "option"
| "radio"
| "spinner"
| "status"
| "segment"
| "input"
| "button"
| "scroll"
| "tabs"
| "tab"
| "tree"
| "node"
| "todo"
| "task"
| "form"
| "field"
| "progress"
| "img"
| "editor"
| "wizard"
| "step"
| "callout"
| "icon"
| "table"
| "tr" | "td"
)
}
fn is_interactive_tag(name: &str) -> bool {
matches!(
name,
"select"
| "option"
| "radio"
| "input"
| "button"
| "scroll"
| "tabs"
| "tab"
| "tree"
| "node"
| "form"
| "field"
| "editor"
| "wizard"
| "step"
)
}
fn is_leaf_tag(name: &str) -> bool {
matches!(name, "pre" | "hr" | "spacer" | "radio" | "input" | "progress" | "img")
}
fn has_matching_close(mut after: &str, name: &str) -> bool {
while let Some(at) = after.find("</") {
let tail = &after[at + 2..];
if tail
.strip_prefix(name)
.is_some_and(|tail| tail.starts_with('>'))
{
return true;
}
after = tail;
}
false
}
fn is_custom_tag_at(src: &str, name: &str, at: usize, close: usize) -> bool {
!name.starts_with('/')
&& !is_catalog_tag(name)
&& !is_markdown_html_tag(name)
&& (src[at..=close].trim_end().ends_with("/>") || has_matching_close(&src[close + 1..], name))
}
fn stray_md_close(at: usize) -> ParseError {
ParseError { message: "closing </md> does not match open <nothing>".into(), at }
}
pub fn md_embeds_markup(text: &str) -> bool {
let mut fence: Option<(u8, usize)> = None;
let mut at = 0;
for piece in text.split_inclusive('\n') {
let line = piece.strip_suffix('\n').unwrap_or(piece);
let line = line.strip_suffix('\r').unwrap_or(line);
let trimmed = line.trim_start();
let prefix = &line[..line.len() - trimmed.len()];
let spaces = prefix.bytes().take_while(|&b| b == b' ').count();
let indented = spaces >= 4 || prefix.contains('\t');
if let Some((marker, length)) = fence {
let run = trimmed.bytes().take_while(|&b| b == marker).count();
if run >= length {
fence = None;
}
at += piece.len();
continue;
}
let marker = trimmed.as_bytes().first().copied();
let run = marker.map_or(0, |m| trimmed.bytes().take_while(|&b| b == m).count());
if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented {
fence = Some((marker.unwrap_or_default(), run));
at += piece.len();
continue;
}
let tag_at = at + prefix.len();
if !indented
&& let Some((name, close)) = line_tag(trimmed, tag_at)
&& (is_md_block_tag(name)
|| is_interactive_tag(name)
|| is_custom_tag_at(text, name, tag_at, close))
{
return true;
}
at += piece.len();
}
false
}
fn child_props(parent: &Props) -> Props {
let mut child = Props::new();
for prop in [
Prop::Fg,
Prop::Bold,
Prop::Dim,
Prop::Italic,
Prop::Underline,
Prop::Reverse,
Prop::Strike,
Prop::Truncate,
] {
if let Some(value) = parent.get(prop).cloned()
&& !matches!(value, PropValue::Gradient(_))
{
child.try_set(prop, value).unwrap();
}
}
child
}
macro_rules! replay_props {
($component:ident, $props:expr) => {
for prop in <Prop as strum::IntoEnumIterator>::iter() {
if let Some(value) = $props.get(prop).cloned() {
$component = $component.with(prop, value);
}
}
};
}
fn boxed_component<T: Component + 'static>(mut component: T, props: Props) -> Box<dyn Component> {
*component.props_mut() = props;
Box::new(component)
}
fn build(tag: &str, props: Props, children: Vec<Cached>, body: &Str) -> Option<Box<dyn Component>> {
macro_rules! configured {
($component:expr) => {{
let mut component = $component;
replay_props!(component, props);
boxed_component(component, props)
}};
}
Some(match tag {
"col" => configured!(Col::new().child(children)),
"row" => configured!(Row::new().child(children)),
"box" => configured!(Boxed::new().child(children)),
"text" => configured!(TextLeaf::new().text(body.clone())),
"pre" => configured!(Pre::new().text(body.clone())),
"md" => configured!(Markdown::text_of(body.clone()).child(children)),
"latex" => configured!(Latex::new().text(body.clone())),
"hr" => configured!(Hr::new()),
"spacer" => configured!(Spacer::new()),
"select" => configured!(Select::new()),
"table" => configured!(Table::new()),
"radio" => configured!(Radio::new()),
"spinner" => configured!(Spinner::new().label(body.clone())),
"input" => configured!(Input::new()),
"button" => configured!(Button::new().child(body.clone())),
"scroll" => configured!(Scroll::new().child(children)),
"tabs" => configured!(Tabs::new().child(children)),
"tree" => configured!(Tree::new()),
"todo" => configured!(Todo::new()),
"form" => configured!(Form::new()),
"progress" => configured!(Progress::new()),
"img" => configured!(Img::new()),
"editor" => configured!(EditorPane::new()),
"wizard" => configured!(Wizard::new().child(children)),
"callout" => configured!(Callout::new().text(body.clone())),
"icon" => {
let name = if body.is_empty() {
props.str_of(Prop::Icon).cloned().unwrap_or_default()
} else {
body.clone()
};
configured!(Icon::named(name))
},
"option" | "segment" | "tab" | "node" | "field" | "step" | "task" | "tr" | "td" => {
return None;
},
_ => return None,
})
}
fn finish_element(
tag: &str,
props: Props,
mut parts: Vec<Parsed>,
body: Str,
at: usize,
) -> Result<Parsed, ParseError> {
match tag {
"option" => {
let label = take_label(&mut parts).unwrap_or_default();
let mut option = SelectOption::new();
replay_props!(option, props);
if !label.is_empty() {
option = option.label(label);
}
for part in parts {
match part {
Parsed::Cell { cell, .. } => option = option.cell(cell),
other => option = option.child(other.into_cached("option")?),
}
}
Ok(Parsed::Option { option, at })
},
"td" => {
let children = cached_children(parts, "td")?;
let mut cell = TableCell::new().child(children);
*cell.props_mut() = props;
Ok(Parsed::Cell { cell, at })
},
"tr" => {
let mut row = TableRow::new();
replay_props!(row, props);
for part in parts {
match part {
Parsed::Cell { cell, .. } => row = row.cell(cell),
other => return Err(parent_error(other.name(), "tr", at)),
}
}
Ok(Parsed::TableRow { row: Box::new(row), at })
},
"table" => {
let mut table = Table::new();
for part in parts {
match part {
Parsed::TableRow { row, .. } => table = table.row(*row),
other => return Err(parent_error(other.name(), "table", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(boxed_component(table, props))),
text: None,
at,
implicit: false,
})
},
"segment" => {
let label = take_label(&mut parts).unwrap_or_default();
if let Some(other) = parts.into_iter().next() {
return Err(parent_error(other.name(), "segment", at));
}
let mut segment = Segment::new();
replay_props!(segment, props);
if !label.is_empty() {
segment = segment.label(label);
}
Ok(Parsed::Segment { segment, at })
},
"tab" => {
let title = props.title().cloned().unwrap_or_else(|| Str::new("tab"));
let children = cached_children(parts, "tab")?;
Ok(Parsed::Tab { title, children, at })
},
"node" => {
let body_label = take_label(&mut parts);
let label = body_label
.or_else(|| props.str_of(Prop::Label).cloned())
.unwrap_or_default();
let mut node = TreeNode::new();
replay_props!(node, props);
if !label.is_empty() {
node = node.label(label);
}
for part in parts {
match part {
Parsed::TreeItem { node: child, .. } => node = node.node(child),
other => return Err(parent_error(other.name(), "node", at)),
}
}
Ok(Parsed::TreeItem { node, at })
},
"task" => {
let body_label = take_label(&mut parts);
let label = body_label
.or_else(|| props.str_of(Prop::Label).cloned())
.unwrap_or_default();
if let Some(status) = props.str_of(Prop::Status)
&& TaskStatus::parse(status).is_none()
{
return Err(ParseError {
message: format!(
"unknown task status {status:?} (use pending|active|done|dropped|blocked)"
),
at,
});
}
let mut task = TodoTask::new();
replay_props!(task, props);
if !label.is_empty() {
task = task.label(label);
}
for part in parts {
match part {
Parsed::Task { task: child, .. } => task = task.task(child),
other => return Err(parent_error(other.name(), "task", at)),
}
}
Ok(Parsed::Task { task, at })
},
"field" => {
let label = take_label(&mut parts);
let children = cached_children(parts, "field")?;
let mut field = Field::new();
replay_props!(field, props);
if let Some(label) = label {
field = field.label(label);
}
if !children.is_empty() {
field = field.child(children);
}
Ok(Parsed::Field { field, at })
},
"step" => {
let title = props.title().cloned().unwrap_or_else(|| Str::new("step"));
let children = cached_children(parts, "step")?;
Ok(Parsed::Step { title, children, at })
},
"select" => {
let mut select = Select::new();
replay_props!(select, props);
*select.props_mut() = props;
for part in parts {
match part {
Parsed::Option { option, .. } => select = select.option(option),
other => return Err(parent_error(other.name(), "select", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(select))),
text: None,
at,
implicit: false,
})
},
"status" => {
let mut status = Status::new();
replay_props!(status, props);
*status.props_mut() = props;
for part in parts {
match part {
Parsed::Segment { segment, .. } => status = status.segment(segment),
other => return Err(parent_error(other.name(), "status", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(status))),
text: None,
at,
implicit: false,
})
},
"editor" => {
let mut editor = EditorPane::new();
replay_props!(editor, props);
let mut has_input = false;
let mut has_status = false;
for part in parts {
let Parsed::Cached { cached, at: child_at, implicit, .. } = part else {
return Err(ParseError {
message: "<editor> takes at most one input child and one <status>".into(),
at,
});
};
if implicit {
return Err(ParseError {
message: "<editor> takes at most one input child and one <status>".into(),
at: child_at,
});
}
if cached.comp().is::<Status>() {
if has_status {
return Err(ParseError {
message: "<editor> takes at most one input child and one <status>".into(),
at: child_at,
});
}
editor = editor.status(cached.into_comp());
has_status = true;
} else {
if has_input {
return Err(ParseError {
message: "<editor> takes at most one input child and one <status>".into(),
at: child_at,
});
}
editor = editor.input(cached.into_comp());
has_input = true;
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(boxed_component(editor, props))),
text: None,
at,
implicit: false,
})
},
"tabs" => {
let mut tabs = Tabs::new();
replay_props!(tabs, props);
*tabs.props_mut() = props;
for part in parts {
match part {
Parsed::Tab { title, children, .. } => tabs = tabs.pane(title, children),
other => return Err(parent_error(other.name(), "tabs", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(tabs))),
text: None,
at,
implicit: false,
})
},
"tree" => {
let mut tree = Tree::new();
replay_props!(tree, props);
*tree.props_mut() = props;
for part in parts {
match part {
Parsed::TreeItem { node, .. } => tree = tree.node(node),
other => return Err(parent_error(other.name(), "tree", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(tree))),
text: None,
at,
implicit: false,
})
},
"todo" => {
let mut todo = Todo::new();
replay_props!(todo, props);
*todo.props_mut() = props;
for part in parts {
match part {
Parsed::Task { task, .. } => todo = todo.task(task),
other => return Err(parent_error(other.name(), "todo", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(todo))),
text: None,
at,
implicit: false,
})
},
"form" => {
let mut form = Form::new();
replay_props!(form, props);
*form.props_mut() = props;
for part in parts {
match part {
Parsed::Field { field, .. } => form = form.field(field),
other => return Err(parent_error(other.name(), "form", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(form))),
text: None,
at,
implicit: false,
})
},
"wizard" => {
let mut wizard = Wizard::new();
replay_props!(wizard, props);
*wizard.props_mut() = props;
for part in parts {
match part {
Parsed::Step { title, children, .. } => wizard = wizard.step(title, children),
other => return Err(parent_error(other.name(), "wizard", at)),
}
}
Ok(Parsed::Cached {
cached: Box::new(Cached::new(Box::new(wizard))),
text: None,
at,
implicit: false,
})
},
_ => {
let children = cached_children(parts, tag)?;
let text = matches!(tag, "text" | "pre" | "md" | "latex").then(|| body.clone());
let component = if is_catalog_tag(tag) {
build(tag, props, children, &body).expect("catalog tag has a component")
} else {
let mut custom = CustomElement::new(tag).child(children);
replay_props!(custom, props);
boxed_component(custom, props)
};
Ok(Parsed::Cached { cached: Box::new(Cached::new(component)), text, at, implicit: false })
},
}
}
fn take_label(parts: &mut Vec<Parsed>) -> Option<Str> {
let index = parts
.iter()
.position(|part| matches!(part, Parsed::Cached { text: Some(_), .. }))?;
match parts.remove(index) {
Parsed::Cached { text: Some(text), .. } => Some(text),
_ => unreachable!(),
}
}
fn markdown_with_parts(
props: Props,
text: Str,
children: Vec<Cached>,
at: usize,
implicit: bool,
) -> Parsed {
let metadata = text.clone();
let component = build("md", props, children, &text).expect("markdown is a catalog component");
Parsed::Cached { cached: Box::new(Cached::new(component)), text: Some(metadata), at, implicit }
}
fn markdown_part(text: Str, props: Props) -> Cached {
let visible = !text.is_empty();
let Parsed::Cached { cached, .. } = markdown_with_parts(props, text, Vec::new(), 0, false)
else {
unreachable!();
};
let mut cached = *cached;
cached.visible = visible;
cached
}
fn markdown_parsed(text: Str, props: Props, at: usize) -> Parsed {
markdown_with_parts(props, text, Vec::new(), at, true)
}
fn apply_attrs(
attrs: &str,
at: usize,
source: &Str,
ctx: &UiContext,
inherited: &Props,
) -> Result<Props, ParseError> {
let mut props = inherited.clone();
for (key, value) in (AttrIter { rest: attrs }) {
if matches!(key, "gradient" | "dir") {
return Err(ParseError {
message: format!("{key} was replaced by fg=/bg= and angle="),
at,
});
}
if value.is_none() && ctx.theme.token(key).is_some() {
props
.try_set(Prop::Fg, PropValue::Token(source.slice_ref(key)))
.map_err(|error| bad(key, &error.value, at))?;
} else if let Some(prop) = Props::prop_of(key) {
let value = match (prop, value) {
(Prop::Title, Some(value)) => PropValue::Str(resolve_icons(ctx.charset, source, value)),
(Prop::Gap | Prop::Grow, None) => PropValue::Str(Str::new("1")),
(_, Some(value)) => PropValue::Str(source.slice_ref(value)),
(_, None) => PropValue::Bool(true),
};
props
.try_set(prop, value)
.map_err(|error| bad(key, &error.value, at))?;
} else {
let value =
value.map_or(PropValue::Bool(true), |value| PropValue::Str(source.slice_ref(value)));
props.set_custom(source.slice_ref(key), value);
}
}
Ok(props)
}
fn bad(key: &str, value: &str, at: usize) -> ParseError {
ParseError { message: format!("bad value {value:?} for attribute {key}"), at }
}
struct AttrIter<'a> {
rest: &'a str,
}
impl<'a> Iterator for AttrIter<'a> {
type Item = (&'a str, Option<&'a str>);
fn next(&mut self) -> Option<Self::Item> {
self.rest = self.rest.trim_start();
if self.rest.is_empty() {
return None;
}
let key_end = self.rest.find(|c: char| c == '=' || c.is_whitespace());
let (key, after) = match key_end {
Some(p) => (&self.rest[..p], &self.rest[p..]),
None => (self.rest, ""),
};
if let Some(after_eq) = after.strip_prefix('=') {
for quote in ['"', '\''] {
if let Some(quoted) = after_eq.strip_prefix(quote) {
let end = quoted.find(quote).unwrap_or(quoted.len());
self.rest = quoted.get(end + 1..).unwrap_or("");
return Some((key, Some("ed[..end])));
}
}
let end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
self.rest = &after_eq[end..];
return Some((key, Some(&after_eq[..end])));
}
self.rest = after;
Some((key, None))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::{Color, Style};
fn child(node: &Cached, index: usize) -> &Cached {
&node.comp().children()[index]
}
#[test]
fn well_known_bad_values_are_parse_errors() {
let ctx = UiContext::default();
assert!(parse(&Str::new("<text fg=nosuch>x</text>"), &ctx).is_err());
assert!(parse(&Str::new("<text fg=\"rgb(300,0)\">x</text>"), &ctx).is_err());
assert!(parse(&Str::new("<text fg=💥>x</text>"), &ctx).is_err());
assert!(parse(&Str::new("<text fg=\"rgb💥(1,2,3)\">x</text>"), &ctx).is_err());
assert!(parse(&Str::new("<text fg=#héx>x</text>"), &ctx).is_err());
}
#[test]
fn chrome_attributes_parse_with_aliases_and_flags() {
let ctx = UiContext::default();
let root = parse(
&Str::new(
"<col><box border=dash on=navy edge=red><text accent reverse strike truncate \
mystery>x</text></box><spacer/></col>",
),
&ctx,
)
.unwrap();
let col = child(&root, 0);
let boxed = child(col, 0);
assert_eq!(boxed.comp().props().border(), Some(Border::Dash));
assert_eq!(boxed.comp().props().style(&ctx.theme).background_color(), Color::Rgb(0, 0, 0x80));
assert_eq!(boxed.comp().props().edge(&ctx.theme), Some(Color::Rgb(0xff, 0, 0)));
assert_eq!(boxed.comp().props().pad().1, 1);
let text = child(boxed, 0).comp().props();
assert_eq!(
text.style(&ctx.theme),
Style::new().fg(ctx.theme.accent).reverse().strikethrough()
);
assert!(text.flag(Prop::Truncate));
assert_eq!(text.custom("mystery"), Some(&PropValue::Bool(true)));
assert_eq!(child(col, 1).comp().props().grow(), Some(1.0));
let overrides =
parse(&Str::new("<col><box pad='2 3'></box><spacer grow=2/></col>"), &ctx).unwrap();
let col = child(&overrides, 0);
assert_eq!(child(col, 0).comp().props().pad(), (2, 3));
assert_eq!(child(col, 1).comp().props().grow(), Some(2.0));
}
#[test]
fn attribute_quoting_styles_are_equivalent() {
let ctx = UiContext::default();
for src in [
"<box title=b><text>x</text></box>",
"<box title='b'><text>x</text></box>",
"<box title=\"b\"><text>x</text></box>",
] {
let root = parse(&Str::new(src), &ctx).unwrap();
assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("b"));
}
let root =
parse(&Str::new("<box title='say \"hi\" now'><text>x</text></box>"), &ctx).unwrap();
assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("say \"hi\" now"));
}
#[test]
fn title_resolves_ico_tags_through_the_charset() {
let unicode = UiContext::default();
let src = Str::new("<box title=\"<ico:folder/> Files\"><text>x</text></box>");
let root = parse(&src, &unicode).unwrap();
assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 Files"));
let ascii = UiContext { charset: Charset::Ascii, ..UiContext::default() };
let root = parse(&src, &ascii).unwrap();
assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("[D] Files"));
let src = Str::new("<hr title=\"<ico:icon.folder/> <ico:nope/>\"/>");
let root = parse(&src, &unicode).unwrap();
assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 nope"));
}
#[test]
fn styles_cascade_to_children() {
let ctx = UiContext::default();
let root = parse(
&Str::new(
"<col fg=#0000ff bold><text>a</text><text fg=#ff0000>b</text><box \
bg=#00ff00><text>c</text></box></col>",
),
&ctx,
)
.unwrap();
let col = child(&root, 0);
let blue = Color::Rgb(0, 0, 0xff);
assert_eq!(child(col, 0).comp().props().style(&ctx.theme), Style::new().fg(blue).bold());
assert_eq!(
child(col, 1).comp().props().style(&ctx.theme),
Style::new().fg(Color::Rgb(0xff, 0, 0)).bold()
);
let nested = child(child(col, 2), 0).comp().props().style(&ctx.theme);
assert_eq!(nested, Style::new().fg(blue).bold());
assert_eq!(nested.background_color(), Color::Default);
}
#[test]
fn gradients_live_in_property_values() {
let ctx = UiContext::default();
let root = parse(
&Str::new(
r##"<box bg="#000000..#ffffff" angle=90><pre fg="accent..info" angle=45> ██
█</pre></box>"##,
),
&ctx,
)
.unwrap();
let boxed = child(&root, 0);
assert_eq!(
boxed.comp().props().get(Prop::Bg),
Some(&PropValue::Gradient(Str::new("#000000..#ffffff")))
);
assert_eq!(boxed.comp().props().angle(), 90);
let pre = child(boxed, 0);
assert_eq!(
pre.comp().props().get(Prop::Fg),
Some(&PropValue::Gradient(Str::new("accent..info")))
);
assert_eq!(pre.comp().props().angle(), 45);
for source in ["<pre gradient=\"accent..info\">x</pre>", "<pre dir=h>x</pre>"] {
assert!(parse(&Str::new(source), &ctx).is_err(), "{source}");
}
}
#[test]
fn custom_elements_require_a_complete_tag_pair() {
let ctx = UiContext::default();
let root = parse(&Str::new("<panel mystery><text>x</text></panel>"), &ctx).unwrap();
let panel = child(&root, 0);
assert_eq!(panel.comp().props().custom("mystery"), Some(&PropValue::Bool(true)));
assert_eq!(panel.comp().children().len(), 1);
let literal = parse(&Str::new("before <panel> after"), &ctx).unwrap();
assert_eq!(literal.comp().children().len(), 1);
}
#[test]
fn markdown_html_stays_literal_but_line_start_custom_elements_embed() {
let ctx = UiContext::default();
let html = parse(&Str::new("<md>before <span>inside</span> after</md>"), &ctx).unwrap();
assert!(child(&html, 0).comp().children().is_empty());
let custom = parse(&Str::new("<md>before\n<panel/>\nafter</md>"), &ctx).unwrap();
assert_eq!(child(&custom, 0).comp().children().len(), 2);
}
}