use std::collections::HashMap;
use std::ops::Range;
use crate::document::EditDelta;
use crate::markdown::parse_offsets;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Site {
pub(crate) label: String,
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) is_definition: bool,
}
pub(crate) fn scan(source: &str) -> Vec<Site> {
let bytes = source.as_bytes();
let mut sites = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'^' && !is_escaped(bytes, i) {
let mut j = i + 2;
while j < bytes.len() && bytes[j] != b']' && bytes[j] != b'\n' {
j += 1;
}
if j < bytes.len() && bytes[j] == b']' {
let label = source[i + 2..j].to_string();
if !label.is_empty() {
let end = j + 1;
let is_definition = bytes.get(end) == Some(&b':') && is_line_start(source, i);
sites.push(Site {
label,
start: i,
end,
is_definition,
});
i = end;
continue;
}
}
i = j.max(i + 1);
} else {
i += 1;
}
}
sites
}
pub(crate) fn is_escaped(bytes: &[u8], pos: usize) -> bool {
let mut backslashes = 0usize;
let mut k = pos;
while k > 0 && bytes[k - 1] == b'\\' {
backslashes += 1;
k -= 1;
}
backslashes % 2 == 1
}
fn is_line_start(source: &str, pos: usize) -> bool {
let line_start = source[..pos].rfind('\n').map(|n| n + 1).unwrap_or(0);
source[line_start..pos]
.chars()
.all(|c| c == ' ' || c == '\t')
}
pub fn next_footnote_number(source: &str) -> u32 {
scan(source)
.iter()
.filter_map(|s| s.label.parse::<u32>().ok())
.max()
.map(|m| m + 1)
.unwrap_or(1)
}
pub fn insert_footnote(source: &str, cursor_byte: usize) -> (EditDelta, usize) {
let n = next_footnote_number(source);
let inserted = format!("[^{n}]");
let cursor_target = cursor_byte + inserted.len();
let delta = EditDelta {
offset: cursor_byte,
removed: String::new(),
inserted,
};
(delta, cursor_target)
}
pub fn label_at(source: &str, cursor_byte: usize) -> Option<String> {
scan(source)
.into_iter()
.find(|s| {
let end = if s.is_definition { s.end + 1 } else { s.end };
cursor_byte >= s.start && cursor_byte <= end
})
.map(|s| s.label)
}
pub fn renumber_footnotes(source: &str) -> Option<EditDelta> {
let sites = scan(source);
let mapping = numeric_renumber_mapping(&sites);
if mapping.is_empty() {
return None;
}
let new = rewrite_labels(source, &sites, &mapping);
EditDelta::diff(source, &new)
}
pub fn delete_footnote(source: &str, label: &str) -> Option<EditDelta> {
let sites = scan(source);
if !sites.iter().any(|s| s.label == label) {
return None;
}
let mut spans: Vec<Range<usize>> = sites
.iter()
.filter(|s| s.label == label && !s.is_definition)
.map(|s| s.start..s.end)
.collect();
for (lbl, range) in parse_offsets::footnote_definition_ranges(source) {
if lbl == label {
spans.push(range);
}
}
spans.sort_by_key(|r| r.start);
let mut merged: Vec<Range<usize>> = Vec::new();
for sp in spans {
match merged.last_mut() {
Some(last) if sp.start <= last.end => last.end = last.end.max(sp.end),
_ => merged.push(sp),
}
}
let mut stripped = String::with_capacity(source.len());
let mut last = 0;
for sp in &merged {
stripped.push_str(&source[last..sp.start]);
last = sp.end;
}
stripped.push_str(&source[last..]);
let renumbered = match renumber_footnotes(&stripped) {
Some(d) => d.apply_to_string(&stripped),
None => stripped,
};
EditDelta::diff(source, &renumbered)
}
fn numeric_renumber_mapping(sites: &[Site]) -> HashMap<String, usize> {
fn push_if_numeric(label: &str, order: &mut Vec<String>) {
if label.parse::<u32>().is_ok() && !order.iter().any(|l| l == label) {
order.push(label.to_string());
}
}
let mut order: Vec<String> = Vec::new();
for s in sites.iter().filter(|s| !s.is_definition) {
push_if_numeric(&s.label, &mut order);
}
for s in sites.iter().filter(|s| s.is_definition) {
push_if_numeric(&s.label, &mut order);
}
order
.into_iter()
.enumerate()
.map(|(i, label)| (label, i + 1))
.collect()
}
fn rewrite_labels(source: &str, sites: &[Site], mapping: &HashMap<String, usize>) -> String {
let mut out = String::with_capacity(source.len());
let mut last = 0;
for s in sites {
if let Some(num) = mapping.get(&s.label) {
out.push_str(&source[last..s.start]);
out.push_str(&format!("[^{num}]"));
last = s.end;
}
}
out.push_str(&source[last..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_number_continues_from_highest() {
assert_eq!(next_footnote_number("a[^1] b[^3]\n\n[^1]: x\n[^3]: y\n"), 4);
assert_eq!(next_footnote_number("no footnotes here\n"), 1);
assert_eq!(next_footnote_number("a[^note] b[^2]\n"), 3);
}
#[test]
fn insert_places_marker_and_cursor() {
let src = "Claim.\n\n[^1]: prior.\n";
let cursor = "Claim.".len(); let (delta, target) = insert_footnote(src, cursor);
assert_eq!(delta.inserted, "[^2]");
assert_eq!(delta.removed, "");
assert_eq!(delta.offset, cursor);
assert_eq!(target, cursor + "[^2]".len());
let out = delta.apply_to_string(src);
assert!(out.starts_with("Claim.[^2]"));
}
#[test]
fn label_at_finds_reference_and_definition() {
let src = "see[^1] x\n\n[^1]: note\n";
let ref_byte = src.find("[^1]").unwrap() + 1;
assert_eq!(label_at(src, ref_byte).as_deref(), Some("1"));
let def_byte = src.rfind("[^1]").unwrap(); assert_eq!(label_at(src, def_byte).as_deref(), Some("1"));
assert_eq!(label_at(src, src.find("see").unwrap()), None);
}
#[test]
fn renumber_orders_by_first_reference() {
let src = "A[^2] B[^1]\n\n[^2]: two\n[^1]: one\n";
let delta = renumber_footnotes(src).expect("renumber needed");
let out = delta.apply_to_string(src);
assert_eq!(out, "A[^1] B[^2]\n\n[^1]: two\n[^2]: one\n");
}
#[test]
fn renumber_leaves_named_labels_untouched() {
let src = "A[^note] B[^2]\n\n[^note]: n\n[^2]: two\n";
let delta = renumber_footnotes(src).expect("renumber needed");
let out = delta.apply_to_string(src);
assert_eq!(out, "A[^note] B[^1]\n\n[^note]: n\n[^1]: two\n");
}
#[test]
fn renumber_returns_none_when_already_sequential() {
let src = "A[^1] B[^2]\n\n[^1]: one\n[^2]: two\n";
assert_eq!(renumber_footnotes(src), None);
}
#[test]
fn delete_removes_refs_and_definition_then_renumbers() {
let src = "A[^1] B[^2] C[^1]\n\n[^1]: one\n[^2]: two\n";
let delta = delete_footnote(src, "1").expect("delete needed");
let out = delta.apply_to_string(src);
assert_eq!(out, "A B[^1] C\n\n[^1]: two\n");
}
#[test]
fn delete_missing_label_is_noop() {
let src = "A[^1]\n\n[^1]: one\n";
assert_eq!(delete_footnote(src, "9"), None);
}
#[test]
fn escaped_reference_is_not_a_footnote() {
assert!(scan(r"an \[^1] escaped marker").is_empty());
assert_eq!(next_footnote_number(r"a \[^5] b"), 1);
let sites = scan(r"a \\[^2] b");
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].label, "2");
}
#[test]
fn is_escaped_counts_backslashes() {
assert!(is_escaped(br"\[", 1)); assert!(!is_escaped(br"\\[", 2)); assert!(is_escaped(br"\\\[", 3)); assert!(!is_escaped(b"[", 0)); }
#[test]
fn delete_removes_multiline_definition_in_full() {
let src = "A[^1] end\n\n[^1]: first line\n continuation line\n";
let delta = delete_footnote(src, "1").expect("delete needed");
let out = delta.apply_to_string(src);
assert_eq!(out, "A end\n\n");
assert!(
!out.contains("continuation line"),
"continuation line should not be orphaned: {out:?}"
);
}
#[test]
fn delete_handles_reference_inside_definition_body() {
let src = "A[^1] B[^2]\n\n[^1]: see [^1] again\n[^2]: two\n";
let delta = delete_footnote(src, "1").expect("delete needed");
let out = delta.apply_to_string(src);
assert_eq!(out, "A B[^1]\n\n[^1]: two\n");
}
}