#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttrBlock {
pub classes: Vec<String>,
pub id: Option<String>,
pub kvs: Vec<(String, String)>,
pub width: Option<&'static str>,
}
impl AttrBlock {
pub fn is_empty(&self) -> bool {
self.classes.is_empty()
&& self.id.is_none()
&& self.kvs.is_empty()
&& self.width.is_none()
}
pub fn get(&self, key: &str) -> Option<&str> {
self.kvs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn class_string(&self) -> String {
self.classes.join(" ")
}
fn set_kv(&mut self, key: String, value: String) {
if let Some(slot) = self.kvs.iter_mut().find(|(k, _)| k == &key) {
slot.1 = value;
} else {
self.kvs.push((key, value));
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttrError {
MissingOpenBrace,
UnclosedBrace,
UnterminatedQuote,
EmptyValue { key: String },
InvalidKey { token: String },
}
pub fn parse_attrs(input: &str) -> Result<AttrBlock, AttrError> {
let mut chars = input.char_indices().peekable();
skip_ws(&mut chars);
match chars.next() {
Some((_, '{')) => {}
_ => return Err(AttrError::MissingOpenBrace),
}
let mut block = AttrBlock::default();
loop {
skip_ws(&mut chars);
match chars.peek().copied() {
None => return Err(AttrError::UnclosedBrace),
Some((_, '}')) => {
chars.next();
return Ok(block);
}
Some((_, '.')) => {
chars.next();
let class = read_bareword(&mut chars);
if !class.is_empty() {
block.classes.push(class);
}
}
Some((_, '#')) => {
chars.next();
let id = read_bareword(&mut chars);
if !id.is_empty() {
block.id = Some(id);
}
}
Some((_, c)) if is_key_start(c) => {
let key = read_key(&mut chars);
skip_ws_inline(&mut chars);
match chars.peek().copied() {
Some((_, '=')) => {
chars.next();
skip_ws_inline(&mut chars);
let value = read_value(&mut chars, &key)?;
block.set_kv(key, value);
}
_ => {
if let Some(width) = match_width_token(&key) {
block.width = Some(width);
} else {
return Err(AttrError::InvalidKey { token: key });
}
}
}
}
Some((_, c)) => {
let mut token = String::new();
token.push(c);
chars.next();
while let Some(&(_, ch)) = chars.peek() {
if ch.is_whitespace() || ch == '}' {
break;
}
token.push(ch);
chars.next();
}
return Err(AttrError::InvalidKey { token });
}
}
}
}
fn is_key_start(c: char) -> bool {
c.is_ascii_alphabetic()
}
fn match_width_token(s: &str) -> Option<&'static str> {
match s {
"body" => Some("body"),
"wide" => Some("wide"),
"page" => Some("page"),
"screen" | "full" => Some("screen"),
_ => None,
}
}
fn is_key_continue(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn is_bareword(c: char) -> bool {
matches!(c,
'A'..='Z' | 'a'..='z' | '0'..='9' |
':' | '/' | '.' | '-' | '_'
)
}
fn skip_ws<I>(iter: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = (usize, char)>,
{
while let Some(&(_, c)) = iter.peek() {
if c.is_whitespace() {
iter.next();
} else {
break;
}
}
}
fn skip_ws_inline<I>(iter: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = (usize, char)>,
{
skip_ws(iter);
}
fn read_bareword<I>(iter: &mut std::iter::Peekable<I>) -> String
where
I: Iterator<Item = (usize, char)>,
{
let mut s = String::new();
while let Some(&(_, c)) = iter.peek() {
if is_bareword(c) {
s.push(c);
iter.next();
} else {
break;
}
}
s
}
fn read_key<I>(iter: &mut std::iter::Peekable<I>) -> String
where
I: Iterator<Item = (usize, char)>,
{
let mut s = String::new();
if let Some(&(_, c)) = iter.peek() {
if is_key_start(c) {
s.push(c);
iter.next();
} else {
return s;
}
}
while let Some(&(_, c)) = iter.peek() {
if is_key_continue(c) {
s.push(c);
iter.next();
} else {
break;
}
}
s
}
fn read_value<I>(iter: &mut std::iter::Peekable<I>, key: &str) -> Result<String, AttrError>
where
I: Iterator<Item = (usize, char)>,
{
match iter.peek().copied() {
Some((_, '"')) => {
iter.next();
read_quoted(iter)
}
Some((_, c)) if is_bareword(c) => Ok(read_bareword(iter)),
_ => Err(AttrError::EmptyValue { key: key.to_string() }),
}
}
fn read_quoted<I>(iter: &mut std::iter::Peekable<I>) -> Result<String, AttrError>
where
I: Iterator<Item = (usize, char)>,
{
let mut s = String::new();
loop {
match iter.next() {
None => return Err(AttrError::UnterminatedQuote),
Some((_, '"')) => return Ok(s),
Some((_, '\\')) => match iter.next() {
None => return Err(AttrError::UnterminatedQuote),
Some((_, ch)) => s.push(ch),
},
Some((_, ch)) => s.push(ch),
}
}
}
pub fn brace_depth(s: &str, start_depth: i32) -> i32 {
let mut depth = start_depth;
let mut in_quote = false;
let mut chars = s.chars();
while let Some(c) = chars.next() {
if in_quote {
match c {
'"' => in_quote = false,
'\\' => {
chars.next();
}
_ => {}
}
continue;
}
match c {
'"' => in_quote = true,
'{' => depth += 1,
'}' => depth = (depth - 1).max(0),
_ => {}
}
}
depth
}
pub fn gather_multi_line_attrs(
single_line_args: &str,
following_lines: &[&str],
) -> (Option<String>, usize) {
let depth_after_first = brace_depth(single_line_args, 0);
if depth_after_first == 0 {
return (None, 0);
}
let mut combined = single_line_args.to_string();
let mut depth = depth_after_first;
let mut consumed = 0;
for &line in following_lines {
combined.push('\n');
combined.push_str(line);
consumed += 1;
depth = brace_depth(line, depth);
if depth == 0 {
return (Some(combined), consumed);
}
}
(Some(combined), consumed)
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(s: &str) -> AttrBlock {
parse_attrs(s).expect("parse_attrs should succeed")
}
fn err(s: &str) -> AttrError {
parse_attrs(s).expect_err("parse_attrs should fail")
}
#[test]
fn empty_block() {
let b = ok("{}");
assert!(b.is_empty());
}
#[test]
fn whitespace_only_block() {
let b = ok("{ }");
assert!(b.is_empty());
}
#[test]
fn missing_open_brace_errors() {
assert_eq!(err("foo=bar"), AttrError::MissingOpenBrace);
}
#[test]
fn unclosed_brace_errors() {
assert_eq!(err("{.foo"), AttrError::UnclosedBrace);
}
#[test]
fn single_class() {
let b = ok("{.primary}");
assert_eq!(b.classes, vec!["primary"]);
assert_eq!(b.class_string(), "primary");
}
#[test]
fn multiple_classes_space_separated() {
let b = ok("{.primary .large}");
assert_eq!(b.classes, vec!["primary", "large"]);
assert_eq!(b.class_string(), "primary large");
}
#[test]
fn classes_preserve_source_order() {
let b = ok("{.first .second .third}");
assert_eq!(b.classes, vec!["first", "second", "third"]);
}
#[test]
fn class_with_dash_and_digit() {
let b = ok("{.btn-primary .v2}");
assert_eq!(b.classes, vec!["btn-primary", "v2"]);
}
#[test]
fn dot_with_no_name_skipped() {
let b = ok("{. .real}");
assert_eq!(b.classes, vec!["real"]);
}
#[test]
fn single_id() {
let b = ok("{#hero}");
assert_eq!(b.id.as_deref(), Some("hero"));
}
#[test]
fn multiple_ids_last_wins() {
let b = ok("{#first #second}");
assert_eq!(b.id.as_deref(), Some("second"));
}
#[test]
fn id_and_class_in_same_block() {
let b = ok("{#main .container}");
assert_eq!(b.id.as_deref(), Some("main"));
assert_eq!(b.classes, vec!["container"]);
}
#[test]
fn key_with_bareword_integer() {
let b = ok("{cols=3}");
assert_eq!(b.get("cols"), Some("3"));
}
#[test]
fn key_with_ratio_value() {
let b = ok("{cols=1:1:2}");
assert_eq!(b.get("cols"), Some("1:1:2"));
}
#[test]
fn key_with_path_value() {
let b = ok("{image=hero.jpg}");
assert_eq!(b.get("image"), Some("hero.jpg"));
}
#[test]
fn key_with_dotted_path_value() {
let b = ok("{image=path/to/file.jpg}");
assert_eq!(b.get("image"), Some("path/to/file.jpg"));
}
#[test]
fn key_with_negative_int_value() {
let b = ok("{offset=-5}");
assert_eq!(b.get("offset"), Some("-5"));
}
#[test]
fn multiple_kvs() {
let b = ok("{cols=3 image=hero.jpg gap=2}");
assert_eq!(b.get("cols"), Some("3"));
assert_eq!(b.get("image"), Some("hero.jpg"));
assert_eq!(b.get("gap"), Some("2"));
}
#[test]
fn duplicate_key_last_wins() {
let b = ok("{cols=2 cols=4}");
assert_eq!(b.get("cols"), Some("4"));
}
#[test]
fn key_with_dash_and_underscore() {
let b = ok("{button-text=foo my_field=bar}");
assert_eq!(b.get("button-text"), Some("foo"));
assert_eq!(b.get("my_field"), Some("bar"));
}
#[test]
fn key_with_quoted_simple() {
let b = ok(r#"{button="Request access"}"#);
assert_eq!(b.get("button"), Some("Request access"));
}
#[test]
fn key_with_quoted_punctuation() {
let b = ok(r#"{description="One email. No newsletter."}"#);
assert_eq!(b.get("description"), Some("One email. No newsletter."));
}
#[test]
fn quoted_value_with_escaped_quote() {
let b = ok(r#"{label="say \"hi\""}"#);
assert_eq!(b.get("label"), Some(r#"say "hi""#));
}
#[test]
fn quoted_value_with_escaped_backslash() {
let b = ok(r#"{path="C:\\Users\\me"}"#);
assert_eq!(b.get("path"), Some(r"C:\Users\me"));
}
#[test]
fn quoted_value_with_brace_inside() {
let b = ok(r#"{template="{name}"}"#);
assert_eq!(b.get("template"), Some("{name}"));
}
#[test]
fn quoted_value_can_be_empty() {
let b = ok(r#"{label=""}"#);
assert_eq!(b.get("label"), Some(""));
}
#[test]
fn unterminated_quote_errors() {
assert_eq!(
err(r#"{button="oops}"#),
AttrError::UnterminatedQuote
);
}
#[test]
fn multi_line_attrs() {
let b = ok("{\n placeholder=\"you@domain.com\"\n button=\"Request access\"\n}");
assert_eq!(b.get("placeholder"), Some("you@domain.com"));
assert_eq!(b.get("button"), Some("Request access"));
}
#[test]
fn multi_line_with_classes_and_kvs() {
let b = ok("{\n .primary\n .large\n cols=3\n image=hero.jpg\n}");
assert_eq!(b.classes, vec!["primary", "large"]);
assert_eq!(b.get("cols"), Some("3"));
assert_eq!(b.get("image"), Some("hero.jpg"));
}
#[test]
fn tab_separator_works() {
let b = ok("{.foo\tcols=3}");
assert_eq!(b.classes, vec!["foo"]);
assert_eq!(b.get("cols"), Some("3"));
}
#[test]
fn key_with_no_value_errors() {
let e = err("{cols=}");
assert!(matches!(e, AttrError::EmptyValue { ref key } if key == "cols"));
}
#[test]
fn key_without_equals_errors() {
assert!(matches!(err("{cols}"), AttrError::InvalidKey { .. }));
assert!(matches!(err("{flush}"), AttrError::InvalidKey { .. }));
}
#[test]
fn width_token_body() {
let b = ok("{body}");
assert_eq!(b.width, Some("body"));
assert!(b.classes.is_empty());
assert!(b.id.is_none());
assert!(b.kvs.is_empty());
}
#[test]
fn width_token_wide() {
let b = ok("{wide}");
assert_eq!(b.width, Some("wide"));
}
#[test]
fn width_token_page() {
let b = ok("{page}");
assert_eq!(b.width, Some("page"));
}
#[test]
fn width_token_screen() {
let b = ok("{screen}");
assert_eq!(b.width, Some("screen"));
}
#[test]
fn width_token_full_aliases_to_screen() {
let b = ok("{full}");
assert_eq!(b.width, Some("screen"));
}
#[test]
fn width_token_with_class() {
let b = ok("{wide .showcase}");
assert_eq!(b.width, Some("wide"));
assert_eq!(b.classes, vec!["showcase"]);
}
#[test]
fn width_token_with_kv() {
let b = ok("{cols=3 wide}");
assert_eq!(b.width, Some("wide"));
assert_eq!(b.get("cols"), Some("3"));
}
#[test]
fn width_token_repeated_last_wins() {
let b = ok("{wide page}");
assert_eq!(b.width, Some("page"));
}
#[test]
fn width_token_does_not_become_class() {
let b = ok("{wide}");
assert!(b.classes.is_empty());
}
#[test]
fn width_token_with_explicit_dot_is_a_class_not_width() {
let b = ok("{.wide}");
assert_eq!(b.classes, vec!["wide"]);
assert!(b.width.is_none());
}
#[test]
fn key_starting_with_digit_is_invalid_token() {
let e = err("{3cols=2}");
assert!(matches!(e, AttrError::InvalidKey { .. }));
}
#[test]
fn lonely_equals_is_invalid_token() {
let e = err("{=foo}");
assert!(matches!(e, AttrError::InvalidKey { .. }));
}
#[test]
fn class_string_joins_with_spaces() {
let b = ok("{.alpha .beta .gamma}");
assert_eq!(b.class_string(), "alpha beta gamma");
}
#[test]
fn class_string_empty_when_no_classes() {
let b = ok("{cols=3}");
assert_eq!(b.class_string(), "");
}
#[test]
fn get_returns_none_for_missing_key() {
let b = ok("{cols=3}");
assert!(b.get("rows").is_none());
}
#[test]
fn spec_subscribe_form_multi_line() {
let b = ok("{\n placeholder=\"you@domain.com\"\n button=\"Request access\"\n}");
assert_eq!(b.get("placeholder"), Some("you@domain.com"));
assert_eq!(b.get("button"), Some("Request access"));
assert!(b.classes.is_empty());
assert!(b.id.is_none());
}
#[test]
fn spec_buttons_classes_only() {
let b = ok("{.primary .large}");
assert_eq!(b.classes, vec!["primary", "large"]);
assert!(b.kvs.is_empty());
}
#[test]
fn spec_gallery_cols_and_class() {
let b = ok("{cols=3 .showcase}");
assert_eq!(b.get("cols"), Some("3"));
assert_eq!(b.classes, vec!["showcase"]);
}
#[test]
fn spec_grid_ratio_cols() {
let b = ok("{cols=1:1:2}");
assert_eq!(b.get("cols"), Some("1:1:2"));
}
#[test]
fn spec_hero_image_path() {
let b = ok("{image=hero.jpg}");
assert_eq!(b.get("image"), Some("hero.jpg"));
}
#[test]
fn spec_pure_css_region_class_and_id() {
let b = ok("{.tagline #intro}");
assert_eq!(b.classes, vec!["tagline"]);
assert_eq!(b.id.as_deref(), Some("intro"));
}
#[test]
fn leading_whitespace_before_brace_ok() {
let b = ok(" {.foo}");
assert_eq!(b.classes, vec!["foo"]);
}
#[test]
fn brace_depth_tracks_simple_open_close() {
assert_eq!(brace_depth("{}", 0), 0);
assert_eq!(brace_depth("{", 0), 1);
assert_eq!(brace_depth("}", 1), 0);
}
#[test]
fn brace_depth_ignores_braces_in_quoted_strings() {
assert_eq!(brace_depth(r#"{key="{name}"}"#, 0), 0);
assert_eq!(brace_depth(r#"{key="{"}"#, 0), 0);
}
#[test]
fn brace_depth_handles_escaped_quote() {
assert_eq!(brace_depth(r#"{label="say \"hi\""}"#, 0), 0);
}
#[test]
fn brace_depth_clamps_at_zero_for_orphan_close() {
assert_eq!(brace_depth("}", 0), 0);
}
#[test]
fn gather_no_brace_returns_none_zero_consumed() {
let (out, consumed) = gather_multi_line_attrs("plain text", &["following"]);
assert!(out.is_none());
assert_eq!(consumed, 0);
}
#[test]
fn gather_balanced_single_line_returns_none() {
let (out, consumed) = gather_multi_line_attrs("{.foo}", &["body"]);
assert!(out.is_none());
assert_eq!(consumed, 0);
}
#[test]
fn gather_two_line_block_returns_combined() {
let (out, consumed) = gather_multi_line_attrs("{", &[".foo", "}", "body"]);
assert_eq!(consumed, 2);
let combined = out.expect("multi-line should return a combined string");
let parsed = parse_attrs(&combined).expect("combined attrs should parse");
assert_eq!(parsed.classes, vec!["foo"]);
}
#[test]
fn gather_three_line_block_with_kvs() {
let (out, consumed) = gather_multi_line_attrs(
"{",
&[" placeholder=\"you@domain.com\"", " button=\"Go\"", "}", "body"],
);
assert_eq!(consumed, 3);
let parsed = parse_attrs(&out.unwrap()).unwrap();
assert_eq!(parsed.get("placeholder"), Some("you@domain.com"));
assert_eq!(parsed.get("button"), Some("Go"));
}
#[test]
fn gather_unclosed_returns_what_it_has() {
let (out, consumed) = gather_multi_line_attrs("{", &[".foo", ".bar"]);
assert_eq!(consumed, 2);
assert!(out.is_some());
}
#[test]
fn gather_quoted_brace_does_not_count() {
let (out, consumed) = gather_multi_line_attrs(
"{",
&[r#" template="say }"#, r#" end"#, "}"],
);
assert_eq!(consumed, 3);
assert!(out.is_some());
}
}