pub fn transform_block_refs(content: &str) -> (String, Vec<String>) {
let mut block_ids: Vec<String> = Vec::new();
let mut in_fence = false;
let mut fence_char = ' ';
let mut output_lines: Vec<String> = Vec::new();
for line in content.lines() {
if in_fence {
let trimmed = line.trim_start();
let closes = trimmed.starts_with(fence_char)
&& trimmed.chars().take(3).all(|c| c == fence_char)
&& trimmed.trim_matches(fence_char).trim().is_empty();
if closes {
in_fence = false;
}
output_lines.push(line.to_string());
continue;
}
let trimmed = line.trim_start();
let fence_rest = trimmed
.strip_prefix("```")
.map(|r| ('`', r))
.or_else(|| trimmed.strip_prefix("~~~").map(|r| ('~', r)));
if let Some((candidate_char, rest)) = fence_rest {
if !rest.contains(candidate_char) {
fence_char = candidate_char;
in_fence = true;
output_lines.push(line.to_string());
continue;
}
}
let line_stripped = line.trim_end();
if let Some(id) = extract_block_id(line_stripped) {
let suffix_len = 1 + 1 + id.len(); #[allow(clippy::string_slice)]
let prefix = &line_stripped[..line_stripped.len() - suffix_len];
block_ids.push(id.to_string());
let transformed = format!("{} <span id=\"{}\"></span>", prefix, id);
output_lines.push(transformed);
} else {
output_lines.push(line.to_string());
}
}
let mut output = output_lines.join("\n");
if content.ends_with('\n') {
output.push('\n');
}
(output, block_ids)
}
fn extract_block_id(line: &str) -> Option<&str> {
let caret_pos = line.rfind(" ^")?;
#[allow(clippy::string_slice)]
let id = &line[caret_pos + 2..];
if id.is_empty() {
return None;
}
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
Some(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_block_id_from_paragraph() {
let input = "This is important. ^my-block";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, "This is important. <span id=\"my-block\"></span>");
assert_eq!(ids, vec!["my-block"]);
}
#[test]
fn test_multiple_block_ids() {
let input = "First paragraph. ^block-one\n\nSecond paragraph. ^block-two";
let (out, ids) = transform_block_refs(input);
assert_eq!(
out,
"First paragraph. <span id=\"block-one\"></span>\n\nSecond paragraph. <span id=\"block-two\"></span>"
);
assert_eq!(ids, vec!["block-one", "block-two"]);
}
#[test]
fn test_block_id_in_code_not_transformed() {
let input = "Normal line. ^ref1\n\n```\nCode line. ^not-a-ref\n```\n\nAfter code. ^ref2";
let (out, ids) = transform_block_refs(input);
assert!(out.contains("<span id=\"ref1\"></span>"));
assert!(out.contains("<span id=\"ref2\"></span>"));
assert!(out.contains("Code line. ^not-a-ref"));
assert!(!out.contains("<span id=\"not-a-ref\">"));
assert_eq!(ids, vec!["ref1", "ref2"]);
}
#[test]
fn test_no_block_ids() {
let input = "Just a plain paragraph.\n\nAnother paragraph.";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, input);
assert!(ids.is_empty());
}
#[test]
fn test_block_id_must_be_at_line_end() {
let input = "text ^mid more text";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, input);
assert!(ids.is_empty());
}
#[test]
fn test_block_id_alphanumeric_only() {
let input = "text ^invalid!id";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, input);
assert!(ids.is_empty());
let input2 = "text ^also_invalid";
let (out2, ids2) = transform_block_refs(input2);
assert_eq!(out2, input2);
assert!(ids2.is_empty());
}
#[test]
fn test_trailing_whitespace_after_id() {
let input = "text ^my-id ";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, "text <span id=\"my-id\"></span>");
assert_eq!(ids, vec!["my-id"]);
}
#[test]
fn test_empty_content() {
let (out, ids) = transform_block_refs("");
assert_eq!(out, "");
assert!(ids.is_empty());
}
#[test]
fn test_tilde_fence_preserved() {
let input = "Before. ^ref1\n\n~~~\nfenced ^skip\n~~~\n\nAfter. ^ref2";
let (out, ids) = transform_block_refs(input);
assert!(out.contains("<span id=\"ref1\"></span>"));
assert!(out.contains("<span id=\"ref2\"></span>"));
assert!(out.contains("fenced ^skip"));
assert!(!out.contains("<span id=\"skip\">"));
assert_eq!(ids, vec!["ref1", "ref2"]);
}
#[test]
fn test_id_with_only_hyphens_rejected() {
let input = "text ^---";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, "text <span id=\"---\"></span>");
assert_eq!(ids, vec!["---"]);
}
#[test]
fn test_content_with_trailing_newline_preserved() {
let input = "Hello. ^block\n";
let (out, ids) = transform_block_refs(input);
assert_eq!(out, "Hello. <span id=\"block\"></span>\n");
assert_eq!(ids, vec!["block"]);
}
}