use std::ops::Range;
use crate::{
Error, Result,
check::{Placement, Repair, Spot, heading_col},
markdown::{Body, Segments, render_anchor},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub lines: Range<usize>,
pub text: String,
}
impl Edit {
pub fn replace(lines: Range<usize>, new: &[String]) -> Self {
Edit {
lines,
text: new.iter().map(|l| format!("{l}\n")).collect(),
}
}
}
pub type Rename = (Option<(usize, usize)>, String, String);
#[derive(Debug, Clone, Copy)]
pub struct AnchorEdit<'a> {
pub body: &'a Body,
pub placements: &'a [Placement],
pub text: &'a str,
pub renames: &'a [Rename],
}
impl AnchorEdit<'_> {
fn drops(&self, line: usize) -> bool {
self.body.anchors.iter().any(|a| a.line == line && a.alone)
}
fn before(&self, line: usize, raw: &str) -> Vec<String> {
let ids: Vec<String> = self
.placements
.iter()
.filter(|p| p.spot == Spot::Before(line))
.map(|p| render_anchor(&p.id))
.collect();
if ids.is_empty() {
return Vec::new();
}
let prefix: String = raw
.chars()
.take_while(|c| matches!(c, ' ' | '\t' | '>'))
.collect();
vec![
format!("{prefix}{}", ids.concat()),
prefix.trim_end().to_string(),
]
}
fn line(&self, line: usize, raw: &str) -> String {
let mut clean = self.body.clean_line(line, raw).into_owned();
if raw.trim_end().ends_with("</a>") && clean.len() < raw.len() {
clean.truncate(clean.trim_end().len());
}
let mut at: Vec<(usize, &str)> = self
.placements
.iter()
.filter_map(|p| match p.spot {
Spot::Inline(l, col) if l == line => Some((col, p.id.as_str())),
Spot::Heading(l) if l == line => {
Some((heading_col(self.body, self.text, line), p.id.as_str()))
}
_ => None,
})
.collect();
let clean = self.rename(line, clean, &mut at);
insert(clean, &mut at)
}
fn rename(&self, line: usize, mut clean: String, at: &mut [(usize, &str)]) -> String {
let mut found: Vec<(usize, &str, &str)> = Vec::new();
let on_line = |r: &&Rename| {
r.0.is_none_or(|(first, last)| first <= line && line <= last)
};
for (_, old, new) in self.renames.iter().filter(on_line) {
for (pos, _) in clean.match_indices(old.as_str()) {
if !self.body.in_code_span(line, pos) {
found.push((pos, old, new));
}
}
}
found.sort_by_key(|f| std::cmp::Reverse(f.0));
for (pos, old, new) in found {
clean.replace_range(pos..pos + old.len(), new);
for (col, _) in at.iter_mut() {
if *col >= pos + old.len() {
*col = *col + new.len() - old.len();
}
}
}
clean
}
fn heading(&self, line: usize, prefix: &str, text: &str) -> String {
let mut at: Vec<(usize, &str)> = self
.placements
.iter()
.filter(|p| p.spot == Spot::Heading(line))
.map(|p| (prefix.len(), p.id.as_str()))
.collect();
insert(format!("{prefix}{text}"), &mut at)
}
}
fn insert(mut line: String, at: &mut [(usize, &str)]) -> String {
at.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(a.1)));
for (col, id) in at.iter() {
let col = (*col).min(line.len());
if line.is_char_boundary(col) {
line.insert_str(col, &render_anchor(id));
}
}
line
}
pub fn line_edits(
text: &str,
segments: &Segments,
repairs: &[Repair],
anchors: Option<&AnchorEdit<'_>>,
) -> Vec<Edit> {
let lines: Vec<&str> = text.lines().collect();
let blank = |n: usize| lines.get(n - 1).is_some_and(|l| l.trim().is_empty());
let generated = segments.definition.as_ref().map_or(lines.len() + 1, |d| {
let mut first = d.line;
while first > 1 && blank(first - 1) {
first -= 1;
}
first
});
let mut edits = Vec::new();
let mut i = 1;
while i < generated {
if segments.in_region(i) {
i += 1;
continue;
}
let raw = lines[i - 1];
if let Some(a) = anchors
&& a.drops(i)
{
let end = if i + 1 < generated && blank(i + 1) {
i + 2
} else {
i + 1
};
edits.push(Edit::replace(i..end, &[]));
i = end;
continue;
}
let mut new = anchors.map(|a| a.before(i, raw)).unwrap_or_default();
let end = match repairs.iter().find(|r| r.line == i) {
Some(r) => {
new.push(match anchors {
Some(a) => a.heading(i, &r.prefix, &r.text),
None => format!("{}{}", r.prefix, r.text),
});
r.end_line.max(i) + 1
}
None => {
new.push(match anchors {
Some(a) => a.line(i, raw),
None => raw.to_string(),
});
i + 1
}
};
if end != i + 1 || new.len() != 1 || new[0] != raw {
edits.push(Edit::replace(i..end, &new));
}
i = end;
}
edits
}
pub fn apply(text: &str, edits: &[Edit]) -> Result<String> {
let lines: Vec<&str> = text.lines().collect();
let mut sorted: Vec<&Edit> = edits.iter().collect();
sorted.sort_by_key(|e| (e.lines.start, e.lines.end));
let mut out: Vec<&str> = Vec::new();
let mut next = 1;
for e in sorted {
if e.lines.start < next || e.lines.end < e.lines.start || e.lines.end > lines.len() + 1 {
return Err(Error::Internal(format!(
"edit of lines {}..{} overlaps another or leaves the text",
e.lines.start, e.lines.end
)));
}
out.extend(&lines[next - 1..e.lines.start - 1]);
out.extend(e.text.lines());
next = e.lines.end;
}
out.extend(&lines[next - 1..]);
if out.is_empty() {
return Ok(String::new());
}
let mut result = out.join("\n");
result.push('\n');
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchors_go_in_from_the_right() {
let mut at = vec![(0, "a"), (4, "b")];
assert_eq!(
insert("one two".into(), &mut at),
"<a id=\"a\"></a>one <a id=\"b\"></a>two"
);
}
#[test]
fn edits_apply_in_one_pass_and_refuse_an_overlap() {
let text = "a\nb\nc\nd\n";
let edits = [
Edit::replace(2..3, &["B".into(), "B2".into()]),
Edit::replace(4..4, &["before d".into()]),
Edit::replace(4..5, &[]),
Edit::replace(1..1, &["first".into()]),
];
assert_eq!(
apply(text, &edits).unwrap(),
"first\na\nB\nB2\nc\nbefore d\n"
);
assert_eq!(apply(text, &[]).unwrap(), text);
assert_eq!(apply("", &[]).unwrap(), "");
assert_eq!(
apply("a", &[Edit::replace(2..2, &["b".into()])]).unwrap(),
"a\nb\n"
);
let clash = [Edit::replace(1..3, &[]), Edit::replace(2..3, &[])];
assert!(apply(text, &clash).is_err());
assert!(apply(text, &[Edit::replace(4..9, &[])]).is_err());
}
#[test]
fn repairs_are_edits_and_generated_content_is_left_alone() {
let text = "Title\n=====\n\n<!-- host:include x -->\n\nstale\n\n<!-- /host:include -->\n\n<!-- host:links -->\n\n[host:x]: /x.md\n";
let dialect = crate::markdown::Dialect {
namespace: "host",
region: Some("include"),
trailer: Some("links"),
scheme: Some("host"),
};
let segments = crate::markdown::segments(text, 1, &dialect);
let repairs = [Repair::heading(1, 2, 1, "Title".into())];
assert_eq!(
line_edits(text, &segments, &repairs, None),
vec![Edit::replace(1..3, &["# Title".into()])]
);
}
}