#[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 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KvSpan {
pub key: String,
pub value: std::ops::Range<usize>,
pub item: std::ops::Range<usize>,
pub quote: Option<char>,
}
pub fn parse_attrs(input: &str) -> Result<AttrBlock, AttrError> {
parse_attrs_spanned(input).map(|(block, _)| block)
}
pub fn parse_attrs_spanned(input: &str) -> Result<(AttrBlock, Vec<KvSpan>), AttrError> {
let mut kv_spans: Vec<KvSpan> = Vec::new();
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, kv_spans));
}
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((item_start, 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_start = chars.peek().map_or(input.len(), |&(i, _)| i);
let quote = match chars.peek() {
Some(&(_, '"')) => Some('"'),
_ => None,
};
let value = read_value(&mut chars, &key)?;
let value_end = chars.peek().map_or(input.len(), |&(i, _)| i);
kv_spans.push(KvSpan {
key: key.clone(),
value: value_start..value_end,
item: item_start..value_end,
quote,
});
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 == '-'
}
pub fn is_bareword(c: char) -> bool {
matches!(c, ':' | '/' | '.' | '-' | '_') || c.is_alphanumeric()
}
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)]
#[path = "attrs_tests.rs"]
mod tests;