use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use super::body::Body;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Alert {
pub marker: String,
pub content: String,
}
pub fn extract_alerts(body: &Body) -> Vec<Alert> {
let source = body.as_str();
let mut alerts = Vec::new();
let mut depth: usize = 0;
let mut start: Option<usize> = None;
for (event, range) in Parser::new(source).into_offset_iter() {
match event {
Event::Start(Tag::BlockQuote(_)) => {
if depth == 0 {
start = Some(range.start);
}
depth += 1;
}
Event::End(TagEnd::BlockQuote) => {
depth = depth.saturating_sub(1);
if depth == 0 {
if let Some(s) = start.take() {
if let Some(alert) = parse_alert(&source[s..range.end]) {
alerts.push(alert);
}
}
}
}
_ => {}
}
}
alerts
}
fn parse_alert(slice: &str) -> Option<Alert> {
let mut lines: Vec<&str> = Vec::new();
for raw in slice.lines() {
let trimmed = raw.trim_start();
let inner = trimmed.strip_prefix('>')?;
let inner = inner.strip_prefix(' ').unwrap_or(inner);
lines.push(inner);
}
let first = lines.first()?.trim_end();
let marker = parse_marker(first)?;
let content = lines
.get(1..)
.map(|rest| rest.join("\n").trim_end().to_string())
.unwrap_or_default();
Some(Alert { marker, content })
}
fn parse_marker(line: &str) -> Option<String> {
let inner = line.strip_prefix("[!")?.strip_suffix(']')?;
if inner.is_empty() || !inner.chars().all(|c| c.is_ascii_uppercase()) {
return None;
}
Some(inner.to_string())
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn arb_marker() -> impl Strategy<Value = String> {
"[A-Z]{1,12}".prop_map(|s| s.to_string())
}
pub fn arb_alert() -> impl Strategy<Value = Alert> {
(arb_marker(), "[a-z0-9 .,!?\n]{0,80}").prop_map(|(marker, content)| Alert {
marker,
content: content.trim_end().to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn body(s: &str) -> Body {
Body::new(s)
}
#[test]
fn empty_body_yields_no_alerts() {
assert!(extract_alerts(&body("")).is_empty());
}
#[test]
fn body_without_blockquote_yields_no_alerts() {
assert!(extract_alerts(&body("# title\n\nplain prose")).is_empty());
}
#[test]
fn plain_blockquote_without_marker_is_ignored() {
assert!(extract_alerts(&body("> just a quote\n> with two lines")).is_empty());
}
#[test]
fn marker_alone_yields_alert_with_empty_content() {
let alerts = extract_alerts(&body("> [!DECISION]"));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].marker, "DECISION");
assert_eq!(alerts[0].content, "");
}
#[test]
fn marker_with_one_line_of_content() {
let alerts = extract_alerts(&body("> [!DECISION]\n> the chosen path"));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].marker, "DECISION");
assert_eq!(alerts[0].content, "the chosen path");
}
#[test]
fn marker_preserves_inner_markdown() {
let alerts = extract_alerts(&body(
"> [!DECISION]\n> use **GFM** alerts; see `[!MARKER]`.",
));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].content, "use **GFM** alerts; see `[!MARKER]`.");
}
#[test]
fn marker_preserves_blank_lines_inside() {
let src = "> [!DECISION]\n> first paragraph.\n>\n> second paragraph.";
let alerts = extract_alerts(&body(src));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].content, "first paragraph.\n\nsecond paragraph.");
}
#[test]
fn lowercase_marker_is_not_an_alert() {
assert!(extract_alerts(&body("> [!decision]\n> body")).is_empty());
}
#[test]
fn empty_marker_brackets_are_not_an_alert() {
assert!(extract_alerts(&body("> [!]\n> body")).is_empty());
}
#[test]
fn marker_line_with_trailing_text_is_not_an_alert() {
assert!(extract_alerts(&body("> [!DECISION] note\n> body")).is_empty());
}
#[test]
fn marker_must_be_first_line_of_blockquote() {
assert!(extract_alerts(&body("> preface\n> [!DECISION]\n> body")).is_empty());
}
#[test]
fn multiple_alerts_are_returned_in_order() {
let src = "> [!DECISION]\n> first\n\nsome prose\n\n> [!CONTEXT]\n> second";
let alerts = extract_alerts(&body(src));
assert_eq!(alerts.len(), 2);
assert_eq!(alerts[0].marker, "DECISION");
assert_eq!(alerts[0].content, "first");
assert_eq!(alerts[1].marker, "CONTEXT");
assert_eq!(alerts[1].content, "second");
}
#[test]
fn duplicate_marker_returns_both_blocks() {
let src = "> [!DECISION]\n> first\n\n> [!DECISION]\n> second";
let alerts = extract_alerts(&body(src));
assert_eq!(alerts.len(), 2);
assert!(alerts.iter().all(|a| a.marker == "DECISION"));
}
#[test]
fn unknown_marker_is_returned_too() {
let alerts = extract_alerts(&body("> [!FOOBAR]\n> something"));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].marker, "FOOBAR");
}
#[test]
fn nested_blockquote_inside_alert_is_part_of_content() {
let src = "> [!DECISION]\n> outer\n> > inner quote";
let alerts = extract_alerts(&body(src));
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].content, "outer\n> inner quote");
}
proptest! {
#[test]
fn prop_marker_is_uppercase_ascii(name in "[A-Z]{1,8}", content in "[a-z .]{0,40}") {
let src = format!("> [!{name}]\n> {content}");
let alerts = extract_alerts(&Body::new(&src));
prop_assert_eq!(alerts.len(), 1);
prop_assert_eq!(&alerts[0].marker, &name);
prop_assert!(alerts[0].marker.chars().all(|c| c.is_ascii_uppercase()));
}
#[test]
fn prop_lowercase_marker_never_extracts(name in "[a-z]{1,8}", content in "[a-z .]{0,40}") {
let src = format!("> [!{name}]\n> {content}");
let alerts = extract_alerts(&Body::new(&src));
prop_assert!(alerts.is_empty());
}
}
}