pub const DEFAULT_FENCE: &str = "#%";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub directive: String,
pub attrs: Vec<(String, String)>,
pub sections: Vec<(String, String)>,
pub line: usize,
}
impl Item {
pub fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn section(&self, name: &str) -> Option<&str> {
self.sections
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
fn parse_attrs(rest: &str, line_no: usize) -> Result<Vec<(String, String)>, String> {
let mut attrs = Vec::new();
let mut chars = rest.char_indices().peekable();
while let Some(&(start, c)) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
let mut eq = None;
for (i, c) in rest[start..].char_indices() {
if c == '=' {
eq = Some(start + i);
break;
}
if c.is_whitespace() {
break;
}
}
let Some(eq) = eq else {
return Err(format!(
"line {line_no}: attribute '{}' is not key=value",
rest[start..].split_whitespace().next().unwrap_or("")
));
};
let key = rest[start..eq].to_string();
if key.is_empty() {
return Err(format!("line {line_no}: attribute with empty key"));
}
let vstart = eq + 1;
let (value, after) = if rest[vstart..].starts_with('"') {
match rest[vstart + 1..].find('"') {
Some(close) => (
rest[vstart + 1..vstart + 1 + close].to_string(),
vstart + close + 2,
),
None => {
return Err(format!(
"line {line_no}: unterminated quoted value for '{key}'"
));
}
}
} else {
let end = rest[vstart..]
.find(char::is_whitespace)
.map(|i| vstart + i)
.unwrap_or(rest.len());
(rest[vstart..end].to_string(), end)
};
attrs.push((key, value));
while let Some(&(i, _)) = chars.peek() {
if i < after {
chars.next();
} else {
break;
}
}
}
Ok(attrs)
}
pub fn parse(src: &str, fence: &str, item_names: &[&str]) -> Result<Vec<Item>, String> {
if fence.is_empty() {
return Err("fence string must not be empty".to_string());
}
let mut items: Vec<Item> = Vec::new();
let mut open: Option<Item> = None;
let mut section: Option<(String, String)> = None;
let close_section = |item: &mut Item, section: &mut Option<(String, String)>| {
if let Some(s) = section.take() {
item.sections.push(s);
}
};
for (idx, raw) in src.lines().enumerate() {
let line_no = idx + 1;
let fenced = raw
.strip_prefix(fence)
.filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace));
let Some(rest) = fenced else {
match (&mut open, &mut section) {
(_, Some((_, payload))) => {
payload.push_str(raw);
payload.push('\n');
}
(Some(item), None) => {
if !raw.trim().is_empty() {
return Err(format!(
"line {line_no}: stray content inside '{}' item (line {}); payload lines belong in a section",
item.directive, item.line
));
}
}
(None, _) => {
if !raw.trim().is_empty() && !raw.trim_start().starts_with('#') {
return Err(format!(
"line {line_no}: content outside any item; expected a '{fence} <directive>' line"
));
}
}
}
continue;
};
let rest = rest.trim_start();
let (name, attr_rest) = match rest.find(char::is_whitespace) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
if name.is_empty() {
return Err(format!("line {line_no}: fence line with no directive name"));
}
if name == "end" {
let Some(mut item) = open.take() else {
return Err(format!("line {line_no}: 'end' with no open item"));
};
close_section(&mut item, &mut section);
items.push(item);
} else if item_names.contains(&name) {
if let Some(mut item) = open.take() {
close_section(&mut item, &mut section);
items.push(item);
}
open = Some(Item {
directive: name.to_string(),
attrs: parse_attrs(attr_rest, line_no)?,
sections: Vec::new(),
line: line_no,
});
} else {
let Some(item) = open.as_mut() else {
return Err(format!(
"line {line_no}: unknown directive '{name}' (expected one of: {})",
item_names.join(", ")
));
};
if !attr_rest.trim().is_empty() {
return Err(format!(
"line {line_no}: section '{name}' takes no attributes"
));
}
close_section(item, &mut section);
if item.section(name).is_some() {
return Err(format!(
"line {line_no}: duplicate section '{name}' in '{}' item (line {})",
item.directive, item.line
));
}
section = Some((name.to_string(), String::new()));
}
}
if let Some(mut item) = open.take() {
close_section(&mut item, &mut section);
items.push(item);
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_items_attrs_and_verbatim_sections() {
let doc = "\
# a comment outside items
#% edit expect==1 mode=literal file=src/a.rs
#% find
old(\"$x\");
#% replace
new(\"$x\");
extra();
#% end
";
let items = parse(doc, DEFAULT_FENCE, &["edit"]).unwrap();
assert_eq!(items.len(), 1);
let it = &items[0];
assert_eq!(it.directive, "edit");
assert_eq!(it.attr("expect"), Some("=1"));
assert_eq!(it.attr("mode"), Some("literal"));
assert_eq!(it.attr("file"), Some("src/a.rs"));
assert_eq!(it.section("find"), Some(" old(\"$x\");\n"));
assert_eq!(it.section("replace"), Some(" new(\"$x\");\n extra();\n"));
assert_eq!(it.line, 3);
}
#[test]
fn quoted_values_carry_spaces_and_read_unambiguously() {
let items = parse(
"#% edit expect=\"=1\" note=\"two words\"\n#% find\nx\n#% end\n",
DEFAULT_FENCE,
&["edit"],
)
.unwrap();
assert_eq!(items[0].attr("expect"), Some("=1"));
assert_eq!(items[0].attr("note"), Some("two words"));
}
#[test]
fn empty_section_is_zero_lines_and_end_is_implicit_between_items() {
let doc = "#% edit\n#% find\nx\n#% replace\n#% edit\n#% find\ny\n#% replace\nz\n#% end\n";
let items = parse(doc, DEFAULT_FENCE, &["edit"]).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].section("replace"), Some(""));
assert_eq!(items[1].section("replace"), Some("z\n"));
}
#[test]
fn custom_fence_lets_payloads_contain_the_default() {
let doc = "::: edit\n::: find\n#% not a fence here\n::: replace\nok\n::: end\n";
let items = parse(doc, ":::", &["edit"]).unwrap();
assert_eq!(items[0].section("find"), Some("#% not a fence here\n"));
}
#[test]
fn payload_lines_resembling_the_fence_prefix_are_fences() {
let doc = "#% edit\n#% find\n#%x payload\n#% end\n";
let items = parse(doc, DEFAULT_FENCE, &["edit"]).unwrap();
assert_eq!(items[0].section("find"), Some("#%x payload\n"));
}
#[test]
fn errors_are_specific() {
let unknown = parse("#% nonsense\n", DEFAULT_FENCE, &["edit"]).unwrap_err();
assert!(unknown.contains("unknown directive"), "{unknown}");
let stray = parse("stray\n", DEFAULT_FENCE, &["edit"]).unwrap_err();
assert!(stray.contains("outside any item"), "{stray}");
let dup = parse(
"#% edit\n#% find\nx\n#% find\ny\n#% end\n",
DEFAULT_FENCE,
&["edit"],
)
.unwrap_err();
assert!(dup.contains("duplicate section"), "{dup}");
let unq = parse("#% edit expect=\"=1\n", DEFAULT_FENCE, &["edit"]).unwrap_err();
assert!(unq.contains("unterminated"), "{unq}");
let end = parse("#% end\n", DEFAULT_FENCE, &["edit"]).unwrap_err();
assert!(end.contains("no open item"), "{end}");
}
#[test]
fn attribute_only_items_close_implicitly() {
let doc = "#% del path=a.b\n#% del path=c.d\n";
let items = parse(doc, DEFAULT_FENCE, &["del"]).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[1].attr("path"), Some("c.d"));
}
}