use std::collections::BTreeMap;
use std::fmt::Write;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MicronStyle {
pub fg: Option<String>,
pub bg: Option<String>,
pub bold: bool,
pub underline: bool,
pub italic: bool,
pub align: MicronAlign,
}
impl Default for MicronStyle {
fn default() -> Self {
Self {
fg: None,
bg: None,
bold: false,
underline: false,
italic: false,
align: MicronAlign::Left,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MicronAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Debug, PartialEq)]
pub enum InlineSegment {
Text {
content: String,
style: MicronStyle,
},
Link {
label: String,
url: String,
fields: Option<String>,
style: MicronStyle,
},
Field {
name: String,
width: usize,
masked: bool,
default: Option<String>,
style: MicronStyle,
},
Checkbox {
name: String,
value: Option<String>,
label: String,
prechecked: bool,
style: MicronStyle,
},
Radio {
group: String,
value: Option<String>,
label: String,
prechecked: bool,
style: MicronStyle,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum MicronBlock {
Blank,
Comment(String),
Heading {
level: usize,
segments: Vec<InlineSegment>,
},
Text {
segments: Vec<InlineSegment>,
depth: usize,
},
Divider {
char: Option<char>,
},
Literal {
content: String,
},
Table {
align: Option<ParserTableAlign>,
max_width: Option<usize>,
headers: Vec<String>,
rows: Vec<Vec<String>>,
has_separator: bool,
},
Partial {
url: String,
refresh: Option<f64>,
fields: Option<String>,
partial_id: Option<String>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParserTableAlign {
Left,
Center,
Right,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MicronDocument {
pub blocks: Vec<MicronBlock>,
pub cache_secs: Option<u32>,
pub default_fg: Option<String>,
pub default_bg: Option<String>,
pub anchors: BTreeMap<String, usize>,
pub header_blocks: Vec<usize>,
}
struct ParserState {
literal: bool,
table_mode: bool,
table_buffer: Vec<String>,
table_align: Option<ParserTableAlign>,
table_max_width: Option<usize>,
depth: usize,
style: MicronStyle,
default_align: MicronAlign,
default_fg: Option<String>,
default_bg: Option<String>,
pending_anchors: Vec<String>,
rendered_rows: usize,
}
impl Default for ParserState {
fn default() -> Self {
Self {
literal: false,
table_mode: false,
table_buffer: Vec::new(),
table_align: None,
table_max_width: None,
depth: 0,
style: MicronStyle::default(),
default_align: MicronAlign::Left,
default_fg: None,
default_bg: None,
pending_anchors: Vec::new(),
rendered_rows: 0,
}
}
}
pub struct MicronParser;
fn rendered_row_count(block: &MicronBlock) -> usize {
match block {
MicronBlock::Comment(_) => 0,
MicronBlock::Table {
rows,
has_separator,
..
} => rows.len() + if *has_separator { 4 } else { 3 },
_ => 1,
}
}
fn push_block(
doc: &mut MicronDocument,
state: &mut ParserState,
block: MicronBlock,
is_heading: bool,
) {
let row_index = state.rendered_rows;
let row_count = rendered_row_count(&block);
for anchor in state.pending_anchors.drain(..) {
doc.anchors.entry(anchor).or_insert(row_index);
}
if is_heading {
doc.header_blocks.push(row_index);
}
doc.blocks.push(block);
state.rendered_rows += row_count;
}
impl MicronParser {
pub fn parse(input: &str) -> MicronDocument {
let mut doc = MicronDocument::default();
let mut state = ParserState::default();
let lines_iter = input.lines();
for line in lines_iter {
if line.starts_with("#!") {
if let Some(rest) = line.strip_prefix("#!") {
Self::parse_directive(rest, &mut doc, &mut state);
}
continue;
}
if state.table_mode {
if Self::is_table_toggle(line) {
if let Some(block) = Self::render_table(&state) {
push_block(&mut doc, &mut state, block, false);
}
state.table_mode = false;
state.table_buffer.clear();
state.table_align = None;
state.table_max_width = None;
} else {
state.table_buffer.push(line.to_string());
}
continue;
}
if line == "`=" {
state.literal = !state.literal;
continue;
}
if state.literal {
let content = if line == "\\`=" { "`=" } else { line };
push_block(
&mut doc,
&mut state,
MicronBlock::Literal {
content: content.to_string(),
},
false,
);
continue;
}
let line = line.trim_end();
if line.is_empty() {
push_block(&mut doc, &mut state, MicronBlock::Blank, false);
continue;
}
if line.starts_with('#') {
doc.blocks.push(MicronBlock::Comment(line.to_string()));
continue;
}
if Self::is_table_toggle(line) {
state.table_mode = true;
Self::parse_table_header(line, &mut state);
continue;
}
if line.starts_with("`{") && line.ends_with('}') {
let inner = &line[2..line.len() - 1];
if let Some(block) = Self::parse_partial(inner) {
push_block(&mut doc, &mut state, block, false);
}
continue;
}
if line.starts_with('<') && !line.starts_with("<<") {
state.depth = 0;
let rest = &line[1..];
if !rest.is_empty() {
let segments = Self::parse_inline(rest, &mut state);
if !segments.is_empty() {
let depth = state.depth;
push_block(
&mut doc,
&mut state,
MicronBlock::Text { segments, depth },
false,
);
}
}
continue;
}
if line.starts_with('>') && !line.starts_with(">>") {
state.depth = 0;
let rest = &line[1..].trim_start();
if !rest.is_empty() {
let saved_style = state.style.clone();
if rest.contains("`<") {
let segments = Self::parse_inline(rest, &mut state);
state.style = saved_style;
if !segments.is_empty() {
let depth = state.depth;
push_block(
&mut doc,
&mut state,
MicronBlock::Text { segments, depth },
false,
);
}
} else {
let segments = Self::parse_inline(rest, &mut state);
state.style = saved_style;
if !segments.is_empty() {
let slug = Self::slugify_micron(rest);
if !slug.is_empty() {
state.pending_anchors.push(slug);
}
push_block(
&mut doc,
&mut state,
MicronBlock::Heading { level: 1, segments },
true,
);
}
}
}
continue;
}
if line.starts_with(">>") {
let depth = line.chars().take_while(|c| *c == '>').count();
state.depth = depth;
let rest = line[depth..].trim_start();
if !rest.is_empty() && rest.contains("`<") {
state.depth = 0;
let saved_style = state.style.clone();
let segments = Self::parse_inline(line, &mut state);
state.style = saved_style;
if !segments.is_empty() {
let current_depth = state.depth;
push_block(
&mut doc,
&mut state,
MicronBlock::Text {
segments,
depth: current_depth,
},
false,
);
}
} else {
let saved_style = state.style.clone();
let segments = Self::parse_inline(rest, &mut state);
state.style = saved_style;
if !segments.is_empty() {
let slug = Self::slugify_micron(rest);
if !slug.is_empty() {
state.pending_anchors.push(slug);
}
push_block(
&mut doc,
&mut state,
MicronBlock::Heading {
level: depth,
segments,
},
true,
);
}
}
continue;
}
if line == "-" || (line.len() == 2 && line.starts_with('-')) {
let ch = if line.len() == 2 {
Some(line.chars().nth(1).unwrap())
} else {
None
};
push_block(
&mut doc,
&mut state,
MicronBlock::Divider { char: ch },
false,
);
continue;
}
let content = if let Some(stripped) = line.strip_prefix('\\') {
stripped
} else {
line
};
let segments = Self::parse_inline(content, &mut state);
if !segments.is_empty() {
let depth = state.depth;
push_block(
&mut doc,
&mut state,
MicronBlock::Text { segments, depth },
false,
);
}
}
if state.table_mode && !state.table_buffer.is_empty() {
if let Some(block) = Self::render_table(&state) {
push_block(&mut doc, &mut state, block, false);
}
}
doc.default_fg = state.default_fg.clone();
doc.default_bg = state.default_bg.clone();
doc
}
fn parse_directive(rest: &str, doc: &mut MicronDocument, state: &mut ParserState) {
let rest = rest.trim();
if let Some(val) = rest.strip_prefix("c=") {
if let Ok(secs) = val.parse::<u32>() {
doc.cache_secs = Some(secs);
}
} else if let Some(val) = rest.strip_prefix("fg=") {
let color = val.trim().to_string();
state.style.fg = Some(color.clone());
state.default_fg = Some(color);
} else if let Some(val) = rest.strip_prefix("bg=") {
let color = val.trim().to_string();
state.style.bg = Some(color.clone());
state.default_bg = Some(color);
}
}
fn is_table_toggle(line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('`') || trimmed.len() < 2 {
return false;
}
let rest = &trimmed[1..];
rest.starts_with('t')
}
fn parse_table_header(line: &str, state: &mut ParserState) {
let after_t = &line.trim()[2..];
let mut chars = after_t.chars();
if let Some(c) = chars.next() {
match c {
'l' => state.table_align = Some(ParserTableAlign::Left),
'c' => state.table_align = Some(ParserTableAlign::Center),
'r' => state.table_align = Some(ParserTableAlign::Right),
_ => {}
}
}
let width_str: String = chars.collect();
if let Ok(w) = width_str.trim().parse::<usize>() {
state.table_max_width = Some(w);
}
}
fn render_table(state: &ParserState) -> Option<MicronBlock> {
let buffer = &state.table_buffer;
if buffer.len() < 2 {
return None;
}
let parse_row = |line: &str| -> Vec<String> {
line.split('|')
.map(|cell| cell.trim().to_string())
.collect()
};
let headers = parse_row(&buffer[0]);
let mut rows = Vec::new();
let mut data_start = 1;
let mut has_separator = false;
if buffer.len() > 2 {
let separator = buffer[1].trim();
if separator
.chars()
.all(|c| c == '-' || c == '|' || c == ':' || c == ' ')
{
data_start = 2;
has_separator = true;
}
}
for line in &buffer[data_start..] {
let row = parse_row(line);
if !row.is_empty() {
rows.push(row);
}
}
Some(MicronBlock::Table {
align: state.table_align,
max_width: state.table_max_width,
headers,
rows,
has_separator,
})
}
fn parse_partial(input: &str) -> Option<MicronBlock> {
let parts: Vec<&str> = input.split('`').collect();
if parts.is_empty() {
return None;
}
let url = parts[0].trim().to_string();
if url.is_empty() {
return None;
}
let mut refresh = None;
let mut fields = None;
let mut partial_id = None;
if parts.len() > 1 {
if let Ok(secs) = parts[1].trim().parse::<f64>() {
if secs >= 1.0 {
refresh = Some(secs);
}
}
}
if parts.len() > 2 {
let field_str = parts[2].trim().to_string();
if !field_str.is_empty() {
for item in field_str.split('|') {
let item = item.trim();
if let Some(id_val) = item.strip_prefix("pid=") {
partial_id = Some(id_val.to_string());
}
}
fields = Some(field_str);
}
}
Some(MicronBlock::Partial {
url,
refresh,
fields,
partial_id,
})
}
fn slugify_micron(input: &str) -> String {
let mut stripped = String::new();
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '`' {
stripped.push(c);
continue;
}
let Some(command) = chars.peek().copied() else {
stripped.push(c);
break;
};
if command == ':' {
chars.next();
while chars
.peek()
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
{
chars.next();
}
continue;
}
if command == 'F' || command == 'B' {
chars.next();
let truecolor = chars.peek() == Some(&'T');
if truecolor {
chars.next();
}
let count = if truecolor { 6 } else { 3 };
let mut color = String::new();
for _ in 0..count {
if let Some(value) = chars.peek().copied() {
if value.is_ascii_hexdigit() {
color.push(value);
chars.next();
}
}
}
if color.len() == count {
continue;
}
stripped.push('`');
stripped.push(command);
if truecolor {
stripped.push('T');
}
stripped.push_str(&color);
continue;
}
if "!*_=fbacrl`<>{".contains(command) {
chars.next();
continue;
}
stripped.push('`');
}
let mut slug = String::new();
let mut separator = false;
for c in stripped.chars() {
if c.is_ascii_alphanumeric() {
if separator && !slug.is_empty() {
slug.push('-');
}
slug.push(c.to_ascii_lowercase());
separator = false;
} else {
separator = true;
}
}
slug
}
fn parse_inline(input: &str, state: &mut ParserState) -> Vec<InlineSegment> {
let mut segments = Vec::new();
let mut current_text = String::new();
let mut mode = InlineMode::Text;
let mut escape = false;
let mut chars = input.chars().peekable();
let flush_text =
|segments: &mut Vec<InlineSegment>, text: &mut String, style: &MicronStyle| {
if !text.is_empty() {
segments.push(InlineSegment::Text {
content: std::mem::take(text),
style: style.clone(),
});
}
};
#[allow(clippy::while_let_on_iterator)]
while let Some(c) = chars.next() {
match mode {
InlineMode::Text => {
if escape {
escape = false;
current_text.push(c);
continue;
}
match c {
'\\' => {
escape = true;
}
'`' => {
flush_text(&mut segments, &mut current_text, &state.style);
mode = InlineMode::Format;
}
_ => {
current_text.push(c);
}
}
}
InlineMode::Format => match c {
'_' => {
state.style.underline = !state.style.underline;
mode = InlineMode::Text;
}
'!' => {
state.style.bold = !state.style.bold;
mode = InlineMode::Text;
}
'*' => {
state.style.italic = !state.style.italic;
mode = InlineMode::Text;
}
'`' => {
state.style.bold = false;
state.style.underline = false;
state.style.italic = false;
state.style.fg = state.default_fg.clone();
state.style.bg = state.default_bg.clone();
state.style.align = state.default_align;
mode = InlineMode::Text;
}
'F' => {
let color = Self::consume_color(&mut chars);
state.style.fg = Some(color);
mode = InlineMode::Text;
}
'f' => {
state.style.fg = state.default_fg.clone();
mode = InlineMode::Text;
}
'B' => {
let color = Self::consume_color(&mut chars);
state.style.bg = Some(color);
mode = InlineMode::Text;
}
'b' => {
state.style.bg = state.default_bg.clone();
mode = InlineMode::Text;
}
'c' => {
state.style.align = MicronAlign::Center;
mode = InlineMode::Text;
}
'l' => {
state.style.align = MicronAlign::Left;
mode = InlineMode::Text;
}
'r' => {
state.style.align = MicronAlign::Right;
mode = InlineMode::Text;
}
'a' => {
state.style.align = state.default_align;
mode = InlineMode::Text;
}
'[' => {
let link = Self::consume_link(&mut chars);
flush_text(&mut segments, &mut current_text, &state.style);
segments.push(InlineSegment::Link {
label: link.0,
url: link.1,
fields: link.2,
style: state.style.clone(),
});
mode = InlineMode::Text;
}
'<' => {
let field = Self::consume_field(&mut chars);
flush_text(&mut segments, &mut current_text, &state.style);
segments.push(field);
mode = InlineMode::Text;
}
':' => {
let mut anchor = String::new();
while chars
.peek()
.is_some_and(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
{
if let Some(value) = chars.next() {
anchor.push(value);
}
}
if !anchor.is_empty() {
state.pending_anchors.push(anchor);
}
mode = InlineMode::Text;
}
_ => {
current_text.push('`');
current_text.push(c);
mode = InlineMode::Text;
}
},
}
}
if escape {
current_text.push('\\');
}
flush_text(&mut segments, &mut current_text, &state.style);
segments
}
fn consume_color(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
if chars.peek() == Some(&'T') {
chars.next();
let truecolor: String = chars.take(6).collect();
if truecolor.len() == 6 {
return format!("#{}", truecolor);
}
return truecolor;
}
let color: String = chars.take(3).collect();
color
}
fn consume_link(
chars: &mut std::iter::Peekable<std::str::Chars>,
) -> (String, String, Option<String>) {
let mut content = String::new();
let mut depth = 1;
#[allow(clippy::while_let_on_iterator)]
while let Some(c) = chars.next() {
if c == '[' {
depth += 1;
} else if c == ']' {
depth -= 1;
if depth == 0 {
break;
}
}
content.push(c);
}
let parts: Vec<&str> = content.split('`').collect();
match parts.len() {
1 => {
let url = parts[0].to_string();
(url.clone(), url, None)
}
2 => (parts[0].to_string(), parts[1].to_string(), None),
_ => (
parts[0].to_string(),
parts[1].to_string(),
if parts.len() > 2 && !parts[2].is_empty() {
Some(parts[2].to_string())
} else {
None
},
),
}
}
fn consume_field(chars: &mut std::iter::Peekable<std::str::Chars>) -> InlineSegment {
let mut content = String::new();
let mut depth = 1;
#[allow(clippy::while_let_on_iterator)]
while let Some(c) = chars.next() {
if c == '<' {
depth += 1;
} else if c == '>' {
depth -= 1;
if depth == 0 {
break;
}
}
content.push(c);
}
let (field_spec, default) = if let Some(idx) = content.find('`') {
(&content[..idx], Some(content[idx + 1..].to_string()))
} else {
(content.as_str(), None)
};
let parts: Vec<&str> = field_spec.split('|').collect();
if parts.is_empty() || parts[0].is_empty() {
return InlineSegment::Field {
name: String::new(),
width: 24,
masked: false,
default,
style: MicronStyle::default(),
};
}
match parts[0] {
"?" => {
let name = parts.get(1).unwrap_or(&"").to_string();
let value = parts.get(2).and_then(|v| {
if v.is_empty() {
None
} else {
Some(v.to_string())
}
});
let prechecked = parts.get(3).is_some_and(|v| v.contains('*'));
let label = default.clone().unwrap_or_default();
InlineSegment::Checkbox {
name,
value,
label,
prechecked,
style: MicronStyle::default(),
}
}
"^" => {
let group = parts.get(1).unwrap_or(&"").to_string();
let value = parts.get(2).and_then(|v| {
if v.is_empty() {
None
} else {
Some(v.to_string())
}
});
let prechecked = parts.get(3).is_some_and(|v| v.contains('*'));
let label = default.clone().unwrap_or_default();
InlineSegment::Radio {
group,
value,
label,
prechecked,
style: MicronStyle::default(),
}
}
"!" => {
let name = parts.get(1).unwrap_or(&"").to_string();
InlineSegment::Field {
name,
width: 24,
masked: true,
default,
style: MicronStyle::default(),
}
}
_ => {
if let Ok(width) = parts[0].parse::<usize>() {
let name = parts.get(1).unwrap_or(&"").to_string();
InlineSegment::Field {
name,
width,
masked: false,
default,
style: MicronStyle::default(),
}
} else {
InlineSegment::Field {
name: parts[0].to_string(),
width: 24,
masked: false,
default,
style: MicronStyle::default(),
}
}
}
}
}
}
#[derive(Clone, Copy)]
enum InlineMode {
Text,
Format,
}
impl MicronDocument {
pub fn to_plain_text(&self) -> String {
let mut out = String::new();
if let Some(secs) = self.cache_secs {
writeln!(out, "#!c={secs}").unwrap();
}
if let Some(ref fg) = self.default_fg {
writeln!(out, "#!fg={fg}").unwrap();
}
if let Some(ref bg) = self.default_bg {
writeln!(out, "#!bg={bg}").unwrap();
}
for block in &self.blocks {
match block {
MicronBlock::Blank => {
out.push('\n');
}
MicronBlock::Comment(c) => {
writeln!(out, "{c}").unwrap();
}
MicronBlock::Heading { level, segments } => {
let markers = ">".repeat(*level);
let text = Self::segments_to_text(segments);
writeln!(out, "{markers} {text}").unwrap();
}
MicronBlock::Text { segments, depth } => {
let indent = " ".repeat(depth.saturating_sub(1));
let text = Self::segments_to_text(segments);
writeln!(out, "{indent}{text}").unwrap();
}
MicronBlock::Divider { char: Some(ch) } => {
writeln!(out, "-{ch}").unwrap();
}
MicronBlock::Divider { char: None } => {
writeln!(out, "-").unwrap();
}
MicronBlock::Literal { content } => {
writeln!(out, "{content}").unwrap();
}
MicronBlock::Table {
align,
max_width,
headers,
rows,
has_separator,
} => {
let max_cols = headers
.len()
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
if max_cols == 0 {
continue;
}
let align_char = match align {
Some(ParserTableAlign::Left) => Some('l'),
Some(ParserTableAlign::Center) => Some('c'),
Some(ParserTableAlign::Right) => Some('r'),
None => None,
};
let mut table_start = String::from("`t");
if let Some(ac) = align_char {
table_start.push(ac);
}
if let Some(w) = max_width {
let _ = write!(table_start, "{w}");
}
writeln!(out, "{table_start}").unwrap();
let mut col_widths = vec![0usize; max_cols];
for (i, h) in headers.iter().enumerate() {
col_widths[i] = col_widths[i].max(h.len());
}
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < max_cols {
col_widths[i] = col_widths[i].max(cell.len());
}
}
}
let mut header_line = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" | ");
}
let _ = write!(header_line, "{:width$}", h, width = col_widths[i]);
}
writeln!(out, "{header_line}").unwrap();
if *has_separator {
let mut sep = String::new();
for (i, w) in col_widths.iter().enumerate() {
if i > 0 {
sep.push_str("-+-");
}
for _ in 0..*w {
sep.push('-');
}
}
writeln!(out, "{sep}").unwrap();
}
for row in rows {
let mut row_line = String::new();
for (i, cell) in row.iter().enumerate() {
if i > 0 {
row_line.push_str(" | ");
}
let w = col_widths.get(i).copied().unwrap_or(0);
let _ = write!(row_line, "{:width$}", cell, width = w);
}
writeln!(out, "{row_line}").unwrap();
}
writeln!(out, "`t").unwrap();
}
MicronBlock::Partial {
url,
refresh,
fields,
..
} => {
out.push('`');
out.push('{');
out.push_str(url);
if let Some(r) = refresh {
let _ = write!(out, "`{r}");
}
if let Some(ref f) = fields {
let _ = write!(out, "`{f}");
}
out.push('}');
out.push('\n');
}
}
}
out
}
fn segments_to_text(segments: &[InlineSegment]) -> String {
let mut out = String::new();
for seg in segments {
match seg {
InlineSegment::Text { content, .. } => out.push_str(content),
InlineSegment::Link { label, .. } => out.push_str(label),
InlineSegment::Field { name, default, .. } => {
out.push('[');
out.push_str(name);
if let Some(ref d) = default {
out.push('=');
out.push_str(d);
}
out.push(']');
}
InlineSegment::Checkbox {
label, prechecked, ..
} => {
out.push_str(if *prechecked { "[x] " } else { "[ ] " });
out.push_str(label);
}
InlineSegment::Radio {
label, prechecked, ..
} => {
out.push_str(if *prechecked { "(x) " } else { "( ) " });
out.push_str(label);
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_blank_lines() {
let doc = MicronParser::parse("\n\n\n");
assert_eq!(doc.blocks.len(), 3);
assert!(matches!(doc.blocks[0], MicronBlock::Blank));
assert!(matches!(doc.blocks[1], MicronBlock::Blank));
assert!(matches!(doc.blocks[2], MicronBlock::Blank));
}
#[test]
fn test_parse_comments() {
let doc = MicronParser::parse("# This is a comment");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
MicronBlock::Comment(c) => assert_eq!(c, "# This is a comment"),
_ => panic!("expected Comment"),
}
}
#[test]
fn test_parse_cache_directive() {
let doc = MicronParser::parse("#!c=300\nHello");
assert_eq!(doc.cache_secs, Some(300));
}
#[test]
fn test_parse_fg_bg_directives() {
let doc = MicronParser::parse("#!fg=f00\n#!bg=000\nHello");
assert_eq!(doc.default_fg.as_deref(), Some("f00"));
assert_eq!(doc.default_bg.as_deref(), Some("000"));
}
#[test]
fn test_parse_heading() {
let doc = MicronParser::parse(">> Hello World");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
MicronBlock::Heading { level, segments } => {
assert_eq!(*level, 2);
assert_eq!(segments.len(), 1);
match &segments[0] {
InlineSegment::Text { content, .. } => assert_eq!(content, "Hello World"),
_ => panic!("expected Text segment"),
}
}
_ => panic!("expected Heading"),
}
}
#[test]
fn test_parse_multiple_headings() {
let doc = MicronParser::parse("> H1\n>> H2\n>>> H3");
assert_eq!(doc.blocks.len(), 3);
match &doc.blocks[0] {
MicronBlock::Heading { level, .. } => assert_eq!(*level, 1),
_ => panic!("expected Heading level 1"),
}
match &doc.blocks[1] {
MicronBlock::Heading { level, .. } => assert_eq!(*level, 2),
_ => panic!("expected Heading level 2"),
}
match &doc.blocks[2] {
MicronBlock::Heading { level, .. } => assert_eq!(*level, 3),
_ => panic!("expected Heading level 3"),
}
}
#[test]
fn test_parse_explicit_and_heading_anchors() {
let doc = MicronParser::parse(
"`:intro\nWelcome\n> First `!Section`\nBefore `:target inline\n`:target Duplicate ignored\n> Café & More\n`t\nName | Value\n--- | ---\none | 1\ntwo | 2\n`t\n> After Table",
);
assert_eq!(doc.anchors.get("intro"), Some(&0));
assert_eq!(doc.anchors.get("first-section"), Some(&1));
assert_eq!(doc.anchors.get("target"), Some(&2));
assert_eq!(doc.anchors.get("caf-more"), Some(&4));
assert_eq!(doc.anchors.get("after-table"), Some(&11));
assert_eq!(doc.header_blocks, vec![1, 4, 11]);
assert_eq!(doc.blocks.len(), 7);
}
#[test]
fn test_parse_divider() {
let doc = MicronParser::parse("-");
assert_eq!(doc.blocks.len(), 1);
assert!(matches!(
&doc.blocks[0],
MicronBlock::Divider { char: None }
));
}
#[test]
fn test_parse_custom_divider() {
let doc = MicronParser::parse("-=");
assert!(matches!(
&doc.blocks[0],
MicronBlock::Divider { char: Some('=') }
));
}
#[test]
fn test_parse_literal_mode() {
let doc = MicronParser::parse("`=\nSome `!bold` text\nMore literal\n`=");
let literal_count = doc
.blocks
.iter()
.filter(|b| matches!(b, MicronBlock::Literal { .. }))
.count();
assert_eq!(literal_count, 2);
}
#[test]
fn test_parse_literal_escaped_toggle() {
let doc = MicronParser::parse("\\`=`= is literal");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { content, .. } => {
assert!(content.contains("`="));
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text block (escaped literal toggle)"),
}
}
#[test]
fn test_parse_plain_text() {
let doc = MicronParser::parse("Hello World");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
assert_eq!(segments.len(), 1);
match &segments[0] {
InlineSegment::Text { content, .. } => assert_eq!(content, "Hello World"),
_ => panic!("expected Text segment"),
}
}
_ => panic!("expected Text block"),
}
}
#[test]
fn test_parse_bold_toggle() {
let doc = MicronParser::parse("normal `!bold`! normal");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
assert_eq!(segments.len(), 3);
match &segments[0] {
InlineSegment::Text { content, style } => {
assert_eq!(content, "normal ");
assert!(!style.bold);
}
_ => panic!("expected Text"),
}
match &segments[1] {
InlineSegment::Text { content, style } => {
assert_eq!(content, "bold");
assert!(style.bold);
}
_ => panic!("expected bold Text"),
}
match &segments[2] {
InlineSegment::Text { content, style } => {
assert_eq!(content, " normal");
assert!(!style.bold);
}
_ => panic!("expected Text"),
}
}
_ => panic!("expected Text block"),
}
}
#[test]
fn test_parse_italic_toggle() {
let doc = MicronParser::parse("`*italic`* normal");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
match &segments[0] {
InlineSegment::Text { style, .. } => assert!(style.italic),
_ => panic!("expected italic Text"),
}
match &segments[1] {
InlineSegment::Text { style, .. } => assert!(!style.italic),
_ => panic!("expected normal Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_underline_toggle() {
let doc = MicronParser::parse("`_underline`_ normal");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
match &segments[0] {
InlineSegment::Text { style, .. } => assert!(style.underline),
_ => panic!("expected underline Text"),
}
match &segments[1] {
InlineSegment::Text { style, .. } => assert!(!style.underline),
_ => panic!("expected normal Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_double_backtick_reset() {
let doc = MicronParser::parse("`!bold `` reset");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
match &segments[0] {
InlineSegment::Text { style, .. } => assert!(style.bold),
_ => panic!("expected bold Text"),
}
match &segments[1] {
InlineSegment::Text { content, style, .. } => {
assert_eq!(content, " reset");
assert!(!style.bold);
}
_ => panic!("expected reset Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_fg_color() {
let doc = MicronParser::parse("`Ff00red text");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.fg.as_deref(), Some("f00"));
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_fg_truecolor() {
let doc = MicronParser::parse("`FTff5500orange text");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.fg.as_deref(), Some("#ff5500"));
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_bg_color() {
let doc = MicronParser::parse("`B000black bg");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.bg.as_deref(), Some("000"));
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_fg_reset() {
let doc = MicronParser::parse("#!fg=fff\n`Ff00colored`f reset");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
match &segments[0] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.fg.as_deref(), Some("f00"));
}
_ => panic!("expected colored Text"),
}
match &segments[1] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.fg.as_deref(), Some("fff"));
}
_ => panic!("expected reset Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_link() {
let doc = MicronParser::parse("`[Click here`aa00bb11:/page]");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
assert_eq!(segments.len(), 1);
match &segments[0] {
InlineSegment::Link {
label, url, fields, ..
} => {
assert_eq!(label, "Click here");
assert_eq!(url, "aa00bb11:/page");
assert!(fields.is_none());
}
_ => panic!("expected Link"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_link_with_fields() {
let doc = MicronParser::parse("`[Search`url`q]");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Link {
label, url, fields, ..
} => {
assert_eq!(label, "Search");
assert_eq!(url, "url");
assert_eq!(fields.as_deref(), Some("q"));
}
_ => panic!("expected Link"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_link_url_only() {
let doc = MicronParser::parse("`[http://example.com]");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Link { label, url, .. } => {
assert_eq!(label, "http://example.com");
assert_eq!(url, "http://example.com");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_field() {
let doc = MicronParser::parse("`<name`default>");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Field {
name,
width,
masked,
default,
..
} => {
assert_eq!(name, "name");
assert_eq!(*width, 24);
assert!(!masked);
assert_eq!(default.as_deref(), Some("default"));
}
_ => panic!("expected Field"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_field_with_width() {
let doc = MicronParser::parse("`<40|username`>");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Field { name, width, .. } => {
assert_eq!(name, "username");
assert_eq!(*width, 40);
}
_ => panic!("expected Field"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_field_masked() {
let doc = MicronParser::parse("`<!|password`secret>");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Field { name, masked, .. } => {
assert_eq!(name, "password");
assert!(masked);
}
_ => panic!("expected masked Field"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_checkbox() {
let doc = MicronParser::parse("`<?|agree|yes|*`I agree>");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Checkbox {
name,
label,
prechecked,
..
} => {
assert_eq!(name, "agree");
assert_eq!(label, "I agree");
assert!(prechecked);
}
_ => panic!("expected Checkbox"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_radio() {
let doc = MicronParser::parse("`<^|color|red|*`Red>");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Radio {
group,
label,
prechecked,
..
} => {
assert_eq!(group, "color");
assert_eq!(label, "Red");
assert!(prechecked);
}
_ => panic!("expected Radio"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_partial() {
let doc = MicronParser::parse("`{aa00bb:/page`30}");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
MicronBlock::Partial {
url,
refresh,
fields,
..
} => {
assert_eq!(url, "aa00bb:/page");
assert_eq!(*refresh, Some(30.0));
assert!(fields.is_none());
}
_ => panic!("expected Partial"),
}
}
#[test]
fn test_parse_partial_with_fields() {
let doc = MicronParser::parse("`{aa00bb:/page`30`q|sort}");
match &doc.blocks[0] {
MicronBlock::Partial { fields, .. } => {
assert_eq!(fields.as_deref(), Some("q|sort"));
}
_ => panic!("expected Partial"),
}
}
#[test]
fn test_parse_table() {
let input = "`tl\nName | Age\n------|-----\nAlice | 30\nBob | 25\n`t";
let doc = MicronParser::parse(input);
let tables: Vec<_> = doc
.blocks
.iter()
.filter(|b| matches!(b, MicronBlock::Table { .. }))
.collect();
assert_eq!(tables.len(), 1);
match tables[0] {
MicronBlock::Table {
align,
headers,
rows,
..
} => {
assert_eq!(*align, Some(ParserTableAlign::Left));
assert_eq!(headers, &vec!["Name".to_string(), "Age".to_string()]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], vec!["Alice".to_string(), "30".to_string()]);
assert_eq!(rows[1], vec!["Bob".to_string(), "25".to_string()]);
}
_ => panic!("expected Table"),
}
}
#[test]
fn test_parse_escaped_line() {
let doc = MicronParser::parse("\\>not a heading");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { content, .. } => {
assert_eq!(content, ">not a heading");
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text block"),
}
}
#[test]
fn test_parse_depth_reset() {
let doc = MicronParser::parse(">> Heading\n<\nNormal text");
assert_eq!(doc.blocks.len(), 2);
match &doc.blocks[0] {
MicronBlock::Heading { level, .. } => assert_eq!(*level, 2),
_ => panic!("expected Heading"),
}
match &doc.blocks[1] {
MicronBlock::Text { depth, .. } => assert_eq!(*depth, 0),
_ => panic!("expected Text at depth 0"),
}
}
#[test]
fn test_formatting_sticky_across_lines() {
let doc = MicronParser::parse("`!bold\ncontinues");
assert_eq!(doc.blocks.len(), 2);
match &doc.blocks[1] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { style, .. } => assert!(style.bold),
_ => panic!("expected bold continuation"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_inline_escape() {
let doc = MicronParser::parse("text \\`not bold\\` more");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
assert_eq!(segments.len(), 1);
match &segments[0] {
InlineSegment::Text { content, .. } => {
assert_eq!(content, "text `not bold` more");
}
_ => panic!("expected Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_to_plain_text_roundtrip() {
let input = "#!c=300\n> Title\nHello World\n-\nDone";
let doc = MicronParser::parse(input);
let text = doc.to_plain_text();
assert!(text.contains("> Title"));
assert!(text.contains("Hello World"));
assert!(text.contains("-\n"));
assert!(text.contains("Done"));
}
#[test]
fn test_parse_alignment() {
let doc = MicronParser::parse("`ccentered");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => match &segments[0] {
InlineSegment::Text { style, .. } => {
assert_eq!(style.align, MicronAlign::Center);
}
_ => panic!("expected Text"),
},
_ => panic!("expected Text"),
}
}
#[test]
fn test_parse_unrecognized_format_char() {
let doc = MicronParser::parse("before `Xafter");
match &doc.blocks[0] {
MicronBlock::Text { segments, .. } => {
assert_eq!(segments.len(), 2);
match &segments[0] {
InlineSegment::Text { content, .. } => {
assert_eq!(content, "before ");
}
_ => panic!("expected Text"),
}
match &segments[1] {
InlineSegment::Text { content, .. } => {
assert_eq!(content, "`Xafter");
}
_ => panic!("expected Text"),
}
}
_ => panic!("expected Text"),
}
}
#[test]
fn test_table_with_width() {
let input = "`tc80\nA | B\n--|--\n1 | 2\n`t";
let doc = MicronParser::parse(input);
match doc
.blocks
.iter()
.find(|b| matches!(b, MicronBlock::Table { .. }))
{
Some(MicronBlock::Table {
align, max_width, ..
}) => {
assert_eq!(*align, Some(ParserTableAlign::Center));
assert_eq!(*max_width, Some(80));
}
_ => panic!("expected Table"),
}
}
#[test]
fn test_style_leak_from_form_field_heading() {
let doc = MicronParser::parse("> `Ff00colored `<name`>\nplain text");
assert!(doc.blocks.len() >= 2);
match doc.blocks.last() {
Some(MicronBlock::Text { segments, .. }) => {
if let Some(InlineSegment::Text { style, .. }) = segments.first() {
assert!(
style.fg.is_none(),
"fg color should not leak past sanitized heading to subsequent lines"
);
}
}
_ => panic!("expected Text block as last block"),
}
}
}