use pulldown_cmark::{Event, OffsetIter, Parser, Tag};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
Default,
)]
#[serde(rename_all = "lowercase")]
pub enum Position {
#[default]
Append,
Prepend,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct LineRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SectionError {
NotFound,
Duplicate,
}
impl std::fmt::Display for SectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SectionError::NotFound => write!(f, "heading not found"),
SectionError::Duplicate => write!(f, "heading ambiguous"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum RangeError {
OutOfRange,
Inverted,
}
impl std::fmt::Display for RangeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RangeError::OutOfRange => write!(f, "line range out of range"),
RangeError::Inverted => write!(f, "start > end"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum MatchError {
NotFound,
Ambiguous(usize),
}
impl std::fmt::Display for MatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MatchError::NotFound => write!(f, "match not found"),
MatchError::Ambiguous(n) => write!(f, "match ambiguous \u{2014} {n} occurrences"),
}
}
}
fn normalize_block(fragment: &str) -> String {
let trimmed = fragment.trim_end_matches('\n');
format!("\n{trimmed}\n\n")
}
pub fn splice_under_heading(
doc: &str,
heading: &str,
position: Position,
fragment: &str,
) -> Result<String, SectionError> {
let mut headings: Vec<(u8, usize, usize)> = Vec::new();
let mut match_indices: Vec<usize> = Vec::new();
let mut iter: OffsetIter<'_> = Parser::new(doc).into_offset_iter();
while let Some((event, range)) = iter.next() {
let level = match &event {
Event::Start(Tag::Heading { level, .. }) => heading_level_u8(*level),
_ => continue,
};
let title = collect_heading_text(&mut iter);
let h_start = range.start;
let h_line_end = range.end;
let idx = headings.len();
headings.push((level, h_start, h_line_end));
if title.trim() == heading.trim() {
match_indices.push(idx);
}
}
match match_indices.len() {
0 => return Err(SectionError::NotFound),
1 => {}
_ => return Err(SectionError::Duplicate),
}
let matched = match_indices[0];
let (level, _h_start, h_line_end) = headings[matched];
let section_end = headings[matched + 1..]
.iter()
.find(|(lvl, _, _)| *lvl <= level)
.map(|(_, start, _)| *start)
.unwrap_or(doc.len());
let insert_at = match position {
Position::Prepend => h_line_end,
Position::Append => section_end,
};
let block = normalize_block(fragment);
Ok(format!(
"{}{}{}",
&doc[..insert_at],
block,
&doc[insert_at..]
))
}
use super::collect_heading_text;
fn heading_level_u8(level: pulldown_cmark::HeadingLevel) -> u8 {
use pulldown_cmark::HeadingLevel;
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
pub fn replace_line_range(doc: &str, range: LineRange, body: &str) -> Result<String, RangeError> {
let LineRange { start, end } = range;
if start == 0 || end == 0 {
return Err(RangeError::OutOfRange);
}
if start > end {
return Err(RangeError::Inverted);
}
let lines: Vec<&str> = doc.split_inclusive('\n').collect();
if end > lines.len() {
return Err(RangeError::OutOfRange);
}
let prefix: String = lines[..start - 1].concat();
let suffix: String = lines[end..].concat();
Ok(format!("{prefix}{}\n{suffix}", body.trim_matches('\n')))
}
pub fn replace_unique_match(doc: &str, needle: &str, body: &str) -> Result<String, MatchError> {
if needle.is_empty() {
return Err(MatchError::NotFound);
}
let count = doc.matches(needle).count();
match count {
0 => Err(MatchError::NotFound),
1 => Ok(doc.replacen(needle, body, 1)),
_ => Err(MatchError::Ambiguous(count)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn t1_append_under_h2_lands_before_next_heading() {
let doc = "# Title\n\n## Section A\n\nExisting body.\n\n## Section B\n\nOther.\n";
let result = splice_under_heading(doc, "Section A", Position::Append, "New line.").unwrap();
assert!(
result.contains("Existing body.\n\n\nNew line.\n\n## Section B"),
"fragment must be before Section B; got: {result:?}"
);
assert!(result.contains("# Title"), "title must be preserved");
assert!(
result.contains("## Section B\n\nOther."),
"section B must be preserved"
);
}
#[test]
fn t2_prepend_under_h2_inserts_after_heading_line() {
let doc = "## Section A\n\nExisting body.\n";
let result = splice_under_heading(doc, "Section A", Position::Prepend, "First!").unwrap();
assert_eq!(
result, "## Section A\n\nFirst!\n\n\nExisting body.\n",
"prepend must produce exact heading\\n\\nfragment\\n\\n shape: {result:?}"
);
}
#[test]
fn t3_append_under_last_section_lands_at_eof() {
let doc = "## Only Section\n\nSome text.\n";
let result =
splice_under_heading(doc, "Only Section", Position::Append, "New line.").unwrap();
assert!(
result.ends_with("Some text.\n\nNew line.\n\n"),
"fragment must be at EOF; got: {result:?}"
);
}
#[test]
fn t4_section_end_stops_at_same_or_higher_not_child_heading() {
let doc =
"## H2\n\n### H3 Target\n\nBody.\n\n#### H4 Child\n\nDeep.\n\n## H2 Next\n\nAfter.\n";
let result = splice_under_heading(doc, "H3 Target", Position::Append, "New!").unwrap();
let frag_pos = result.find("New!").expect("fragment present");
let h4_pos = result.find("#### H4 Child").expect("H4 present");
let h2_next_pos = result.find("## H2 Next").expect("H2 Next present");
assert!(
h4_pos < frag_pos,
"H4 child must be before fragment (section continues past H4): {result:?}"
);
assert!(
frag_pos < h2_next_pos,
"fragment must be before H2 Next: {result:?}"
);
}
#[test]
fn t5_heading_not_found_returns_not_found_error() {
let doc = "## Real Heading\n\nContent.\n";
let err = splice_under_heading(doc, "Typo Heading", Position::Append, "x").unwrap_err();
assert_eq!(err, SectionError::NotFound);
}
#[test]
fn t6_duplicate_heading_returns_duplicate_error() {
let doc = "## Same\n\nFirst.\n\n## Same\n\nSecond.\n";
let err = splice_under_heading(doc, "Same", Position::Append, "x").unwrap_err();
assert_eq!(err, SectionError::Duplicate);
}
#[test]
fn t7_hash_in_fenced_code_not_matched_as_heading() {
let doc = "## Real\n\n```\n# not a heading\n```\n\nBody.\n";
let result = splice_under_heading(doc, "Real", Position::Append, "Added.").unwrap();
assert!(
result.contains("Added."),
"splice must succeed with only the real heading: {result:?}"
);
}
#[test]
fn t8_setext_heading_matched_and_splice_after_underline() {
let doc = "Section A\n---------\n\nExisting body.\n";
let result =
splice_under_heading(doc, "Section A", Position::Prepend, "Before body!").unwrap();
assert_eq!(
result, "Section A\n---------\n\nBefore body!\n\n\nExisting body.\n",
"prepend after setext underline must produce exact shape: {result:?}"
);
}
#[test]
fn t9_replace_middle_range() {
let doc = "line1\nline2\nline3\nline4\nline5\nline6\n";
let result = replace_line_range(doc, LineRange { start: 3, end: 5 }, "replaced").unwrap();
assert_eq!(
result, "line1\nline2\nreplaced\nline6\n",
"replace_lines [3,5] must produce exact composed output: {result:?}"
);
}
#[test]
fn t10_single_line_replace() {
let doc = "alpha\nbeta\ngamma\n";
let result = replace_line_range(doc, LineRange { start: 2, end: 2 }, "replaced").unwrap();
assert_eq!(
result, "alpha\nreplaced\ngamma\n",
"single-line replace must produce exact composed output: {result:?}"
);
}
#[test]
fn t11_zero_start_or_end_returns_out_of_range() {
let doc = "a\nb\n";
assert_eq!(
replace_line_range(doc, LineRange { start: 0, end: 1 }, "x").unwrap_err(),
RangeError::OutOfRange
);
assert_eq!(
replace_line_range(doc, LineRange { start: 1, end: 0 }, "x").unwrap_err(),
RangeError::OutOfRange
);
}
#[test]
fn t12_end_beyond_line_count_returns_out_of_range() {
let doc = "a\nb\n";
assert_eq!(
replace_line_range(doc, LineRange { start: 1, end: 99 }, "x").unwrap_err(),
RangeError::OutOfRange
);
}
#[test]
fn t13_start_greater_than_end_returns_inverted() {
let doc = "a\nb\nc\n";
assert_eq!(
replace_line_range(doc, LineRange { start: 3, end: 1 }, "x").unwrap_err(),
RangeError::Inverted
);
}
#[test]
fn t14_unique_literal_match_replaced() {
let doc = "The quick brown fox jumps over the lazy dog.\n";
let result = replace_unique_match(doc, "brown fox", "red cat").unwrap();
assert_eq!(result, "The quick red cat jumps over the lazy dog.\n");
}
#[test]
fn t15_index_marker_block_intact_when_editing_sibling_region() {
let doc = concat!(
"# Wiki Index\n",
"\n",
"<!-- HALLOUMINATE:INDEX-START -->\n",
"- [A](a.md)\n",
"<!-- HALLOUMINATE:INDEX-END -->\n",
"\n",
"## Prose Section\n",
"\n",
"Existing content.\n",
);
let result =
splice_under_heading(doc, "Prose Section", Position::Append, "New entry.").unwrap();
assert!(
result.contains("<!-- HALLOUMINATE:INDEX-START -->"),
"INDEX-START marker must be preserved: {result:?}"
);
assert!(
result.contains("<!-- HALLOUMINATE:INDEX-END -->"),
"INDEX-END marker must be preserved: {result:?}"
);
assert!(
result.contains("New entry."),
"splice must be present: {result:?}"
);
}
#[test]
fn t16_newline_hygiene_no_line_fusion() {
let doc = "## Section\n\nExisting.\n";
let result = splice_under_heading(doc, "Section", Position::Append, "Fragment").unwrap();
assert!(
!result.contains("Existing.Fragment"),
"fragment must not fuse with preceding text: {result:?}"
);
let frag_pos = result.find("Fragment").expect("fragment present");
let after_frag = &result[frag_pos + "Fragment".len()..];
assert!(
after_frag.starts_with('\n'),
"fragment must end with newline: after={after_frag:?}"
);
}
#[test]
fn t17_match_not_found_returns_not_found() {
let doc = "Hello world.\n";
assert_eq!(
replace_unique_match(doc, "xyz", "abc").unwrap_err(),
MatchError::NotFound
);
}
#[test]
fn t18_ambiguous_match_returns_ambiguous() {
let doc = "foo bar foo baz\n";
assert_eq!(
replace_unique_match(doc, "foo", "qux").unwrap_err(),
MatchError::Ambiguous(2)
);
}
#[test]
fn t19_empty_needle_treated_as_not_found() {
let doc = "Some text.\n";
assert_eq!(
replace_unique_match(doc, "", "x").unwrap_err(),
MatchError::NotFound
);
}
}