use std::collections::HashSet;
const MAX_LAYOUT_VALUE: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidthMode {
Fit,
Full,
Exact(usize),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BorderMode {
Single,
None,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Padding {
pub x: usize,
pub y: usize,
}
#[derive(Clone, Debug)]
pub struct BoxSpec {
pub title: String,
pub border_color: Option<String>,
pub bg: Option<String>,
pub border: BorderMode,
pub width: WidthMode,
pub padding: Padding,
}
#[derive(Clone, Copy, Debug)]
pub struct IndentSpec {
pub first: usize,
}
#[derive(Clone, Debug)]
pub struct ColumnsSpec {
pub gap: usize,
pub bg: Option<String>,
pub padding: Padding,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColumnWidth {
Fixed(usize),
Flex(usize),
}
#[derive(Clone, Debug)]
pub struct ColumnSpec {
pub width: ColumnWidth,
pub bg: Option<String>,
pub padding: Padding,
}
#[derive(Debug)]
pub enum Node<'a> {
Markup(&'a str),
Box {
spec: BoxSpec,
children: Vec<Node<'a>>,
},
Center {
children: Vec<Node<'a>>,
},
Right {
children: Vec<Node<'a>>,
},
Indent {
spec: IndentSpec,
children: Vec<Node<'a>>,
},
Columns {
spec: ColumnsSpec,
children: Vec<Node<'a>>,
},
Column {
spec: ColumnSpec,
children: Vec<Node<'a>>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ContainerKind {
Box,
Center,
Right,
Indent,
Columns,
Column,
}
impl ContainerKind {
const fn name(self) -> &'static str {
match self {
Self::Box => "box",
Self::Center => "center",
Self::Right => "right",
Self::Indent => "indent",
Self::Columns => "columns",
Self::Column => "column",
}
}
}
#[derive(Clone, Debug)]
enum TagKind {
OpenBox(BoxSpec),
OpenCenter,
OpenRight,
OpenIndent(IndentSpec),
OpenColumns(ColumnsSpec),
OpenColumn(ColumnSpec),
Close(ContainerKind),
}
#[derive(Clone, Debug)]
struct TagToken {
start: usize,
end: usize,
kind: TagKind,
}
pub(crate) fn parse(input: &str, max_depth: usize) -> Result<Vec<Node<'_>>, String> {
let tokens = scan_tags(input)?;
let mut token_index = 0;
let (nodes, _) = parse_nodes(input, &tokens, &mut token_index, 0, None, 0, max_depth)?;
Ok(nodes)
}
#[allow(clippy::too_many_arguments)]
fn parse_nodes<'a>(
input: &'a str,
tokens: &[TagToken],
token_index: &mut usize,
mut cursor: usize,
expected: Option<ContainerKind>,
depth: usize,
max_depth: usize,
) -> Result<(Vec<Node<'a>>, usize), String> {
if depth > max_depth {
return Err(format!(
"structural BBCode nesting exceeds {max_depth} levels"
));
}
let mut nodes = Vec::new();
while let Some(token) = tokens.get(*token_index) {
if token.start > cursor {
nodes.push(Node::Markup(&input[cursor..token.start]));
}
let (kind, node) = match &token.kind {
TagKind::OpenBox(spec) => (ContainerKind::Box, OpenNode::Box { spec: spec.clone() }),
TagKind::OpenCenter => (ContainerKind::Center, OpenNode::Center),
TagKind::OpenRight => (ContainerKind::Right, OpenNode::Right),
TagKind::OpenIndent(spec) => (ContainerKind::Indent, OpenNode::Indent { spec: *spec }),
TagKind::OpenColumns(spec) => (
ContainerKind::Columns,
OpenNode::Columns { spec: spec.clone() },
),
TagKind::OpenColumn(spec) => (
ContainerKind::Column,
OpenNode::Column { spec: spec.clone() },
),
TagKind::Close(actual) if Some(*actual) == expected => {
*token_index += 1;
return Ok((nodes, token.end));
}
TagKind::Close(actual) => {
return Err(match expected {
Some(expected) => format!(
"mismatched structural BBCode at byte {}: expected [/{expected}], found [/{actual}]",
token.start,
expected = expected.name(),
actual = actual.name()
),
None => format!(
"unexpected closing structural BBCode [/{name}] at byte {}",
token.start,
name = actual.name()
),
});
}
};
*token_index += 1;
let (children, end) = parse_nodes(
input,
tokens,
token_index,
token.end,
Some(kind),
depth + 1,
max_depth,
)?;
nodes.push(node.finish(children));
cursor = end;
}
if let Some(expected) = expected {
return Err(format!(
"structural BBCode [{name}] is missing [/{name}]",
name = expected.name()
));
}
if cursor < input.len() {
nodes.push(Node::Markup(&input[cursor..]));
}
Ok((nodes, input.len()))
}
enum OpenNode {
Box { spec: BoxSpec },
Center,
Right,
Indent { spec: IndentSpec },
Columns { spec: ColumnsSpec },
Column { spec: ColumnSpec },
}
impl OpenNode {
fn finish(self, children: Vec<Node<'_>>) -> Node<'_> {
match self {
Self::Box { spec } => Node::Box { spec, children },
Self::Center => Node::Center { children },
Self::Right => Node::Right { children },
Self::Indent { spec } => Node::Indent { spec, children },
Self::Columns { spec } => Node::Columns { spec, children },
Self::Column { spec } => Node::Column { spec, children },
}
}
}
fn scan_tags(input: &str) -> Result<Vec<TagToken>, String> {
let mut tokens = Vec::new();
let mut cursor = 0;
let mut fence = None;
let mut inline_ticks = None;
while let Some((line_start, _, next, line)) = source_line(input, cursor) {
if inline_ticks.is_none() && update_fence(line, &mut fence) {
cursor = next;
continue;
}
if fence.is_none() && !is_indented_code_line(line) {
scan_line(
input,
line_start,
line_start + line.len(),
&mut inline_ticks,
&mut tokens,
)?;
}
cursor = next;
}
Ok(tokens)
}
fn scan_line(
input: &str,
start: usize,
end: usize,
inline_ticks: &mut Option<usize>,
tokens: &mut Vec<TagToken>,
) -> Result<(), String> {
let mut cursor = start;
while cursor < end {
let ch = input[cursor..end]
.chars()
.next()
.expect("cursor remains on a UTF-8 boundary");
if ch == '`' {
let length = input[cursor..end]
.chars()
.take_while(|candidate| *candidate == '`')
.count();
match *inline_ticks {
Some(open) if open == length => *inline_ticks = None,
None if has_matching_backticks(input, cursor + length, length) => {
*inline_ticks = Some(length);
}
_ => {}
}
cursor += length;
continue;
}
if inline_ticks.is_some() {
cursor += ch.len_utf8();
continue;
}
if ch == '\\' {
cursor += ch.len_utf8();
if cursor < end {
cursor += input[cursor..end]
.chars()
.next()
.expect("escaped character exists")
.len_utf8();
}
continue;
}
if ch == '[' {
if let Some(close) = find_tag_end(input, cursor + ch.len_utf8(), end) {
let body = &input[cursor + ch.len_utf8()..close];
if let Some(kind) = parse_structural_tag(body)? {
tokens.push(TagToken {
start: cursor,
end: close + 1,
kind,
});
cursor = close + 1;
continue;
}
}
}
cursor += ch.len_utf8();
}
Ok(())
}
fn find_tag_end(input: &str, start: usize, end: usize) -> Option<usize> {
let mut cursor = start;
let mut quoted = false;
let mut escaped = false;
while cursor < end {
let ch = input[cursor..end].chars().next()?;
if escaped {
escaped = false;
} else if ch == '\\' && quoted {
escaped = true;
} else if ch == '"' {
quoted = !quoted;
} else if ch == ']' && !quoted {
return Some(cursor);
}
cursor += ch.len_utf8();
}
None
}
fn has_matching_backticks(input: &str, mut cursor: usize, expected: usize) -> bool {
while cursor < input.len() {
let Some(offset) = input[cursor..].find('`') else {
return false;
};
cursor += offset;
let length = input[cursor..]
.chars()
.take_while(|candidate| *candidate == '`')
.count();
if length == expected {
return true;
}
cursor += length;
}
false
}
fn is_indented_code_line(line: &str) -> bool {
line.starts_with('\t') || line.bytes().take_while(|byte| *byte == b' ').count() >= 4
}
pub fn is_structural_tag_name(name: &str) -> bool {
matches!(
name,
"box" | "center" | "c" | "right" | "r" | "indent" | "columns" | "cols" | "column" | "col"
)
}
fn parse_structural_tag(body: &str) -> Result<Option<TagKind>, String> {
let body = body.trim();
let lower = body.to_ascii_lowercase();
if let Some(closing) = lower.strip_prefix('/') {
let closing = closing.trim();
return match closing {
"box" => Ok(Some(TagKind::Close(ContainerKind::Box))),
"center" | "c" => Ok(Some(TagKind::Close(ContainerKind::Center))),
"right" | "r" => Ok(Some(TagKind::Close(ContainerKind::Right))),
"indent" => Ok(Some(TagKind::Close(ContainerKind::Indent))),
"columns" | "cols" => Ok(Some(TagKind::Close(ContainerKind::Columns))),
"column" | "col" => Ok(Some(TagKind::Close(ContainerKind::Column))),
value
if [
"box", "center", "c", "right", "r", "indent", "columns", "cols", "column",
"col",
]
.iter()
.any(|name| value.starts_with(&format!("{name} "))) =>
{
Err(format!(
"closing structural BBCode cannot have attributes: [{body}]"
))
}
_ => Ok(None),
};
}
let name_end = body.find(char::is_whitespace).unwrap_or(body.len());
let name = body[..name_end].to_ascii_lowercase();
let attributes = body[name_end..].trim();
match name.as_str() {
"box" => Ok(Some(TagKind::OpenBox(parse_box_attributes(attributes)?))),
"center" | "c" if attributes.is_empty() => Ok(Some(TagKind::OpenCenter)),
"center" | "c" => Err(format!("[{name}] does not accept attributes")),
"right" | "r" if attributes.is_empty() => Ok(Some(TagKind::OpenRight)),
"right" | "r" => Err(format!("[{name}] does not accept attributes")),
"indent" => Ok(Some(TagKind::OpenIndent(parse_indent_attributes(
attributes,
)?))),
"columns" | "cols" => Ok(Some(TagKind::OpenColumns(parse_columns_attributes(
attributes,
)?))),
"column" | "col" => Ok(Some(TagKind::OpenColumn(parse_column_attributes(
attributes,
)?))),
_ => Ok(None),
}
}
fn parse_box_attributes(source: &str) -> Result<BoxSpec, String> {
let attributes = parse_attributes("box", source)?;
let mut title = String::new();
let mut border_color = None;
let mut bg = None;
let mut border = BorderMode::Single;
let mut width = WidthMode::Fit;
let mut p = None;
let mut px = None;
let mut py = None;
for (name, value) in attributes {
match name.as_str() {
"title" => title = value,
"border-color" if !value.trim().is_empty() => border_color = Some(value),
"border-color" => return Err("box border-color cannot be empty".to_string()),
"bg" if !value.trim().is_empty() => bg = Some(value),
"bg" => return Err("box bg cannot be empty".to_string()),
"border" if value.eq_ignore_ascii_case("single") => border = BorderMode::Single,
"border" if value.eq_ignore_ascii_case("none") => border = BorderMode::None,
"border" => return Err(format!("box border must be single or none, got {value:?}")),
"width" if value.eq_ignore_ascii_case("fit") => width = WidthMode::Fit,
"width" if value.eq_ignore_ascii_case("full") => width = WidthMode::Full,
"width" => width = WidthMode::Exact(parse_positive("box width", &value)?),
"p" => p = Some(parse_nonnegative("box p", &value)?),
"px" => px = Some(parse_nonnegative("box px", &value)?),
"py" => py = Some(parse_nonnegative("box py", &value)?),
_ => return Err(format!("unknown box attribute {name:?}")),
}
}
if border == BorderMode::None && !title.trim().is_empty() {
return Err("box title requires border=single".to_string());
}
if border == BorderMode::None && border_color.is_some() {
return Err("box border-color requires border=single".to_string());
}
let padding = resolve_padding(usize::from(border == BorderMode::Single), 0, p, px, py);
let minimum_width = usize::from(border == BorderMode::Single) * 2 + padding.x * 2 + 1;
if matches!(width, WidthMode::Exact(width) if width < minimum_width) {
return Err(format!(
"box width must be at least {minimum_width} with this padding"
));
}
Ok(BoxSpec {
title,
border_color,
bg,
border,
width,
padding,
})
}
fn parse_indent_attributes(source: &str) -> Result<IndentSpec, String> {
let attributes = parse_attributes("indent", source)?;
let mut first = None;
for (name, value) in attributes {
match name.as_str() {
"first" => first = Some(parse_positive("indent first", &value)?),
_ => return Err(format!("unknown indent attribute {name:?}")),
}
}
Ok(IndentSpec {
first: first.ok_or_else(|| "indent requires first=<positive columns>".to_string())?,
})
}
fn parse_columns_attributes(source: &str) -> Result<ColumnsSpec, String> {
let attributes = parse_attributes("columns", source)?;
let mut gap = 2;
let mut bg = None;
let mut p = None;
let mut px = None;
let mut py = None;
for (name, value) in attributes {
match name.as_str() {
"gap" => gap = parse_nonnegative("columns gap", &value)?,
"bg" if !value.trim().is_empty() => bg = Some(value),
"bg" => return Err("columns bg cannot be empty".to_string()),
"p" => p = Some(parse_nonnegative("columns p", &value)?),
"px" => px = Some(parse_nonnegative("columns px", &value)?),
"py" => py = Some(parse_nonnegative("columns py", &value)?),
_ => return Err(format!("unknown columns attribute {name:?}")),
}
}
Ok(ColumnsSpec {
gap,
bg,
padding: resolve_padding(0, 0, p, px, py),
})
}
fn parse_column_attributes(source: &str) -> Result<ColumnSpec, String> {
let attributes = parse_attributes("column", source)?;
let mut width = ColumnWidth::Flex(1);
let mut bg = None;
let mut p = None;
let mut px = None;
let mut py = None;
for (name, value) in attributes {
match name.as_str() {
"width" => width = parse_column_width(&value)?,
"bg" if !value.trim().is_empty() => bg = Some(value),
"bg" => return Err("column bg cannot be empty".to_string()),
"p" => p = Some(parse_nonnegative("column p", &value)?),
"px" => px = Some(parse_nonnegative("column px", &value)?),
"py" => py = Some(parse_nonnegative("column py", &value)?),
_ => return Err(format!("unknown column attribute {name:?}")),
}
}
Ok(ColumnSpec {
width,
bg,
padding: resolve_padding(0, 0, p, px, py),
})
}
fn resolve_padding(
default_x: usize,
default_y: usize,
p: Option<usize>,
px: Option<usize>,
py: Option<usize>,
) -> Padding {
Padding {
x: px.or(p).unwrap_or(default_x),
y: py.or(p).unwrap_or(default_y),
}
}
fn parse_column_width(value: &str) -> Result<ColumnWidth, String> {
if let Some(weight) = value.to_ascii_lowercase().strip_suffix("fr") {
return Ok(ColumnWidth::Flex(parse_positive(
"column flex width",
weight,
)?));
}
Ok(ColumnWidth::Fixed(parse_positive("column width", value)?))
}
fn parse_positive(label: &str, value: &str) -> Result<usize, String> {
let parsed = parse_nonnegative(label, value)?;
if parsed == 0 {
return Err(format!("{label} must be positive, got {value:?}"));
}
Ok(parsed)
}
fn parse_nonnegative(label: &str, value: &str) -> Result<usize, String> {
let parsed = value
.parse::<usize>()
.map_err(|_| format!("{label} must be an integer, got {value:?}"))?;
if parsed > MAX_LAYOUT_VALUE {
return Err(format!(
"{label} must not exceed {MAX_LAYOUT_VALUE}, got {value:?}"
));
}
Ok(parsed)
}
fn parse_attributes(context: &str, source: &str) -> Result<Vec<(String, String)>, String> {
let mut attributes = Vec::new();
let mut names = HashSet::new();
let mut cursor = 0;
while cursor < source.len() {
cursor += source[cursor..]
.chars()
.take_while(|ch| ch.is_whitespace())
.map(char::len_utf8)
.sum::<usize>();
if cursor >= source.len() {
break;
}
let name_start = cursor;
while cursor < source.len()
&& (source.as_bytes()[cursor].is_ascii_alphanumeric()
|| matches!(source.as_bytes()[cursor], b'_' | b'-'))
{
cursor += 1;
}
if cursor == name_start {
return Err(format!(
"invalid {context} attribute near {:?}",
&source[cursor..]
));
}
let name = source[name_start..cursor].to_ascii_lowercase();
cursor += source[cursor..]
.chars()
.take_while(|ch| ch.is_whitespace())
.map(char::len_utf8)
.sum::<usize>();
if source.as_bytes().get(cursor) != Some(&b'=') {
return Err(format!("{context} attribute {name:?} requires =value"));
}
cursor += 1;
cursor += source[cursor..]
.chars()
.take_while(|ch| ch.is_whitespace())
.map(char::len_utf8)
.sum::<usize>();
let (value, end) = parse_attribute_value(context, source, cursor)?;
cursor = end;
if cursor < source.len()
&& !source[cursor..]
.chars()
.next()
.expect("attribute suffix exists")
.is_whitespace()
{
return Err(format!(
"{context} attribute {name:?} must be followed by whitespace"
));
}
if !names.insert(name.clone()) {
return Err(format!("duplicate {context} attribute {name:?}"));
}
attributes.push((name, value));
}
Ok(attributes)
}
fn parse_attribute_value(
context: &str,
source: &str,
start: usize,
) -> Result<(String, usize), String> {
if start >= source.len() {
return Err(format!("{context} attribute value is missing"));
}
if source.as_bytes()[start] != b'"' {
let end = source[start..]
.find(char::is_whitespace)
.map_or(source.len(), |offset| start + offset);
if end == start {
return Err(format!("{context} attribute value is missing"));
}
return Ok((source[start..end].to_string(), end));
}
let mut value = String::new();
let mut cursor = start + 1;
let mut escaped = false;
while cursor < source.len() {
let ch = source[cursor..]
.chars()
.next()
.expect("cursor remains on a UTF-8 boundary");
cursor += ch.len_utf8();
if escaped {
value.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
return Ok((value, cursor));
} else {
value.push(ch);
}
}
Err(format!(
"quoted {context} attribute is missing its closing quote"
))
}
#[derive(Clone, Copy)]
struct Fence {
marker: char,
length: usize,
}
fn source_line(input: &str, start: usize) -> Option<(usize, usize, usize, &str)> {
if start >= input.len() {
return None;
}
let relative_end = input[start..].find('\n');
let line_end = relative_end.map_or(input.len(), |offset| start + offset);
let next = relative_end.map_or(input.len(), |_| line_end + 1);
let body_end = line_end - usize::from(input[..line_end].ends_with('\r'));
Some((start, line_end, next, &input[start..body_end]))
}
fn update_fence(line: &str, fence: &mut Option<Fence>) -> bool {
let Some(candidate) = fence_marker(line) else {
return false;
};
match fence {
Some(active)
if candidate.marker == active.marker
&& candidate.length >= active.length
&& fence_suffix_is_empty(line, candidate) =>
{
*fence = None;
true
}
Some(_) => true,
None => {
*fence = Some(candidate);
true
}
}
}
fn fence_marker(line: &str) -> Option<Fence> {
let indent = line.bytes().take_while(|byte| *byte == b' ').count();
if indent > 3 {
return None;
}
let rest = &line[indent..];
let marker = rest.chars().next()?;
if !matches!(marker, '`' | '~') {
return None;
}
let length = rest.chars().take_while(|ch| *ch == marker).count();
(length >= 3).then_some(Fence { marker, length })
}
fn fence_suffix_is_empty(line: &str, fence: Fence) -> bool {
let indent = line.bytes().take_while(|byte| *byte == b' ').count();
line[indent + fence.length..].trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attributes_are_strict_and_support_layout_values() {
let spec = parse_box_attributes(
r##"title="A ] title" border-color="#12abef" bg=panel width=32 border=single p=1 px=3 py=2"##,
)
.unwrap();
assert_eq!(spec.title, "A ] title");
assert_eq!(spec.border_color.as_deref(), Some("#12abef"));
assert_eq!(spec.bg.as_deref(), Some("panel"));
assert_eq!(spec.width, WidthMode::Exact(32));
assert_eq!(spec.padding, Padding { x: 3, y: 2 });
assert_eq!(
parse_box_attributes("border=single").unwrap().padding,
Padding { x: 1, y: 0 }
);
assert_eq!(
parse_box_attributes("border=none").unwrap().padding,
Padding { x: 0, y: 0 }
);
assert!(parse_box_attributes("width=wide").is_err());
assert!(parse_box_attributes("border=none title=a").is_err());
assert!(parse_box_attributes("border=none border-color=red").is_err());
assert!(parse_box_attributes("color=red").is_err());
assert!(parse_box_attributes("title=a title=b").is_err());
assert!(parse_box_attributes("bg=red bg=blue").is_err());
assert!(parse_box_attributes("bg=\"\"").is_err());
assert!(parse_box_attributes("title=\"a\"width=full").is_err());
assert!(parse_box_attributes("p=-1").is_err());
assert!(parse_box_attributes("width=4 px=2").is_err());
}
#[test]
fn parses_indent_and_column_widths_strictly() {
assert_eq!(parse_indent_attributes("first=4").unwrap().first, 4);
assert!(parse_indent_attributes("").is_err());
assert_eq!(
parse_column_attributes("width=3fr").unwrap().width,
ColumnWidth::Flex(3)
);
let column = parse_column_attributes("width=24 bg=#123456").unwrap();
assert_eq!(column.width, ColumnWidth::Fixed(24));
assert_eq!(column.bg.as_deref(), Some("#123456"));
assert_eq!(column.padding, Padding::default());
assert_eq!(
parse_column_attributes("p=2 px=4").unwrap().padding,
Padding { x: 4, y: 2 }
);
assert_eq!(
parse_columns_attributes("gap=3 bg=surface")
.unwrap()
.bg
.as_deref(),
Some("surface")
);
assert_eq!(
parse_columns_attributes("p=3 py=1").unwrap().padding,
Padding { x: 3, y: 1 }
);
assert!(parse_column_attributes("width=0").is_err());
assert!(parse_column_attributes("bg=\"\"").is_err());
assert!(parse_columns_attributes("bg=\"\"").is_err());
}
}