use crate::syntax::SyntaxKind;
use rowan::GreenNodeBuilder;
#[derive(Debug, PartialEq)]
pub struct AttributeBlock {
pub identifier: Option<String>,
pub classes: Vec<String>,
pub key_values: Vec<(String, String)>,
}
pub fn try_parse_trailing_attributes(text: &str) -> Option<(AttributeBlock, &str)> {
let (attrs, before, _) = try_parse_trailing_attributes_with_pos(text)?;
Some((attrs, before))
}
pub fn try_parse_trailing_attributes_with_pos(text: &str) -> Option<(AttributeBlock, &str, usize)> {
let trimmed = text.trim_end();
if !trimmed.ends_with('}') {
return None;
}
let open_brace = find_matching_open_brace_for_trailing_block(trimmed)?;
let before_brace = &trimmed[..open_brace];
if before_brace.trim_end().ends_with(']') {
log::debug!("Skipping attribute parsing for bracketed span: {}", text);
return None;
}
let attr_content = &trimmed[open_brace + 1..trimmed.len() - 1];
let attr_block = parse_attribute_content(attr_content)?;
let before_attrs = trimmed[..open_brace].trim_end();
Some((attr_block, before_attrs, open_brace))
}
fn find_matching_open_brace_for_trailing_block(text: &str) -> Option<usize> {
if !text.ends_with('}') {
return None;
}
let mut stack: Vec<usize> = Vec::new();
let mut in_quote: Option<char> = None;
let mut escaped = false;
let mut end_brace_open = None;
for (idx, ch) in text.char_indices() {
if let Some(q) = in_quote {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == q {
in_quote = None;
}
continue;
}
match ch {
'\'' | '"' => in_quote = Some(ch),
'{' => stack.push(idx),
'}' => {
let open = stack.pop()?;
if idx == text.len() - 1 {
end_brace_open = Some(open);
}
}
_ => {}
}
}
if in_quote.is_some() || !stack.is_empty() {
return None;
}
end_brace_open
}
pub fn parse_attribute_content(content: &str) -> Option<AttributeBlock> {
let mut identifier = None;
let mut classes = Vec::new();
let mut key_values = Vec::new();
let content = content.trim();
if content.is_empty() {
return None; }
let mut pos = 0;
let bytes = content.as_bytes();
while pos < bytes.len() {
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
if pos >= bytes.len() {
break;
}
if bytes[pos] == b'=' {
pos += 1; let start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
pos += 1;
}
if pos > start {
classes.push(format!("={}", &content[start..pos]));
}
} else if bytes[pos] == b'#' {
if identifier.is_none() {
pos += 1; let start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
pos += 1;
}
if pos > start {
identifier = Some(content[start..pos].to_string());
}
} else {
pos += 1;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
pos += 1;
}
}
} else if bytes[pos] == b'.' {
pos += 1; let start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
pos += 1;
}
if pos > start {
classes.push(content[start..pos].to_string());
}
} else {
let key_start = pos;
while pos < bytes.len() && bytes[pos] != b'=' && !bytes[pos].is_ascii_whitespace() {
pos += 1;
}
if pos >= bytes.len() || bytes[pos] != b'=' {
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
pos += 1;
}
continue;
}
let key = content[key_start..pos].to_string();
pos += 1;
let value = if pos < bytes.len() && (bytes[pos] == b'"' || bytes[pos] == b'\'') {
let quote = bytes[pos];
pos += 1; let val_start = pos;
while pos < bytes.len() && bytes[pos] != quote {
pos += 1;
}
let val = content[val_start..pos].to_string();
if pos < bytes.len() {
pos += 1; }
val
} else {
let val_start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
pos += 1;
}
content[val_start..pos].to_string()
};
if !key.is_empty() {
key_values.push((key, value));
}
}
}
if identifier.is_none() && classes.is_empty() && key_values.is_empty() {
return None;
}
Some(AttributeBlock {
identifier,
classes,
key_values,
})
}
pub fn emit_attributes(builder: &mut GreenNodeBuilder, attrs: &AttributeBlock) {
builder.start_node(SyntaxKind::ATTRIBUTE.into());
let mut attr_str = String::from("{");
if let Some(ref id) = attrs.identifier {
attr_str.push('#');
attr_str.push_str(id);
}
for class in &attrs.classes {
if attr_str.len() > 1 {
attr_str.push(' ');
}
if class.starts_with('=') {
attr_str.push_str(class);
} else {
attr_str.push('.');
attr_str.push_str(class);
}
}
for (key, value) in &attrs.key_values {
if attr_str.len() > 1 {
attr_str.push(' ');
}
attr_str.push_str(key);
attr_str.push('=');
attr_str.push('"');
attr_str.push_str(&value.replace('"', "\\\""));
attr_str.push('"');
}
attr_str.push('}');
builder.token(SyntaxKind::ATTRIBUTE.into(), &attr_str);
builder.finish_node();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_id() {
let result = try_parse_trailing_attributes("Heading {#my-id}");
assert!(result.is_some());
let (attrs, before) = result.unwrap();
assert_eq!(before, "Heading");
assert_eq!(attrs.identifier, Some("my-id".to_string()));
assert!(attrs.classes.is_empty());
assert!(attrs.key_values.is_empty());
}
#[test]
fn test_single_class() {
let result = try_parse_trailing_attributes("Text {.myclass}");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(attrs.classes, vec!["myclass"]);
}
#[test]
fn test_multiple_classes() {
let result = try_parse_trailing_attributes("Text {.class1 .class2 .class3}");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(attrs.classes, vec!["class1", "class2", "class3"]);
}
#[test]
fn test_key_value_unquoted() {
let result = try_parse_trailing_attributes("Text {key=value}");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(
attrs.key_values,
vec![("key".to_string(), "value".to_string())]
);
}
#[test]
fn test_key_value_quoted() {
let result = try_parse_trailing_attributes("Text {key=\"value with spaces\"}");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(
attrs.key_values,
vec![("key".to_string(), "value with spaces".to_string())]
);
}
#[test]
fn test_full_attributes() {
let result =
try_parse_trailing_attributes("Heading {#id .class1 .class2 key1=val1 key2=\"val 2\"}");
assert!(result.is_some());
let (attrs, before) = result.unwrap();
assert_eq!(before, "Heading");
assert_eq!(attrs.identifier, Some("id".to_string()));
assert_eq!(attrs.classes, vec!["class1", "class2"]);
assert_eq!(attrs.key_values.len(), 2);
assert_eq!(
attrs.key_values[0],
("key1".to_string(), "val1".to_string())
);
assert_eq!(
attrs.key_values[1],
("key2".to_string(), "val 2".to_string())
);
}
#[test]
fn test_trailing_attributes_with_shortcode_in_quoted_value() {
let text = "Slide Title {background-image='{{< placeholder 100 100 >}}' background-size=\"100px\"}";
let result = try_parse_trailing_attributes(text);
assert!(result.is_some());
let (attrs, before) = result.unwrap();
assert_eq!(before, "Slide Title");
assert_eq!(attrs.key_values.len(), 2);
assert_eq!(
attrs.key_values[0],
(
"background-image".to_string(),
"{{< placeholder 100 100 >}}".to_string()
)
);
assert_eq!(
attrs.key_values[1],
("background-size".to_string(), "100px".to_string())
);
}
#[test]
fn test_no_attributes() {
let result = try_parse_trailing_attributes("Heading with no attributes");
assert!(result.is_none());
}
#[test]
fn test_empty_braces() {
let result = try_parse_trailing_attributes("Heading {}");
assert!(result.is_none());
}
#[test]
fn test_only_first_id_counts() {
let result = try_parse_trailing_attributes("Text {#id1 #id2}");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(attrs.identifier, Some("id1".to_string()));
}
#[test]
fn test_whitespace_handling() {
let result = try_parse_trailing_attributes("Text { #id .class key=val }");
assert!(result.is_some());
let (attrs, _) = result.unwrap();
assert_eq!(attrs.identifier, Some("id".to_string()));
assert_eq!(attrs.classes, vec!["class"]);
assert_eq!(
attrs.key_values,
vec![("key".to_string(), "val".to_string())]
);
}
#[test]
fn test_trailing_whitespace_before_attrs() {
let result = try_parse_trailing_attributes("Heading {#id}");
assert!(result.is_some());
let (_, before) = result.unwrap();
assert_eq!(before, "Heading");
}
}