use std::fmt::Write as _;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Callout {
pub line: usize,
pub number: String,
}
const CONUM_OPEN: &str = "<b class=\"conum\">(";
pub(crate) fn locate(source: &str, rendered: &str) -> Option<Vec<Callout>> {
let found = scan(source);
let expected = parsed(rendered);
if found.len() != expected.len() {
return None;
}
let agrees = found
.iter()
.zip(&expected)
.all(|(callout, number)| &callout.number == number);
agrees.then_some(found)
}
pub(crate) fn strip(source: &str, callouts: &[Callout]) -> String {
if callouts.is_empty() {
return source.to_string();
}
source
.split('\n')
.enumerate()
.map(
|(index, line)| match callouts.iter().find(|c| c.line == index) {
Some(callout) => {
let marker = format!("<{}>", callout.number);
line.strip_suffix(&marker).unwrap_or(line).to_string()
}
None => line.to_string(),
},
)
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn reapply(highlighted: &str, callouts: &[Callout]) -> String {
if callouts.is_empty() {
return highlighted.to_string();
}
highlighted
.split('\n')
.enumerate()
.map(
|(index, line)| match callouts.iter().find(|c| c.line == index) {
Some(callout) => format!("{line}{CONUM_OPEN}{})</b>", callout.number),
None => line.to_string(),
},
)
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn iconize(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find(CONUM_OPEN) {
out.push_str(&rest[..at]);
rest = &rest[at..];
let after = &rest[CONUM_OPEN.len()..];
let number: String = after.chars().take_while(char::is_ascii_digit).collect();
if number.is_empty() {
out.push_str(&rest[..CONUM_OPEN.len()]);
rest = &rest[CONUM_OPEN.len()..];
continue;
}
let _ = write!(out, "<i class=\"conum\" data-value=\"{number}\"></i>");
out.push_str(&rest[..CONUM_OPEN.len()]);
rest = &rest[CONUM_OPEN.len()..];
}
out.push_str(rest);
out
}
fn parsed(rendered: &str) -> Vec<String> {
rendered
.match_indices(CONUM_OPEN)
.filter_map(|(at, _)| {
let rest = rendered.get(at + CONUM_OPEN.len()..)?;
let number: String = rest.chars().take_while(char::is_ascii_digit).collect();
(!number.is_empty()).then_some(number)
})
.collect()
}
fn scan(source: &str) -> Vec<Callout> {
source
.split('\n')
.enumerate()
.filter_map(|(line, text)| {
let inner = text.strip_suffix('>')?;
let (_, number) = inner.rsplit_once('<')?;
(!number.is_empty() && number.chars().all(|c| c.is_ascii_digit())).then(|| Callout {
line,
number: number.to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = "fn main() {\n let x = 1; // <1>\n dbg!(x); // <2>\n}";
const RENDERED: &str = "fn main() {\n let x = 1; // <b class=\"conum\">(1)</b>\n \
dbg!(x); // <b class=\"conum\">(2)</b>\n}";
#[test]
fn finds_the_callouts_the_parser_found() {
let callouts = locate(SOURCE, RENDERED).unwrap_or_default();
assert_eq!(
callouts,
vec![
Callout {
line: 1,
number: "1".to_string()
},
Callout {
line: 2,
number: "2".to_string()
},
]
);
}
#[test]
fn strips_and_puts_back_what_it_took() {
let callouts = locate(SOURCE, RENDERED).unwrap_or_default();
let stripped = strip(SOURCE, &callouts);
assert!(!stripped.contains("<1>"), "marker survived: {stripped}");
assert!(stripped.contains("let x = 1; //"));
let back = reapply(&stripped, &callouts);
assert!(back.contains("<b class=\"conum\">(1)</b>"));
assert!(back.contains("<b class=\"conum\">(2)</b>"));
}
#[test]
fn a_block_with_no_callouts_is_untouched() {
let source = "fn main() {}";
assert_eq!(locate(source, source), Some(Vec::new()));
assert_eq!(strip(source, &[]), source);
assert_eq!(reapply(source, &[]), source);
}
#[test]
fn gives_up_when_the_parser_disagrees() {
let source = "let ok = a < b && c > d;\nlet v: Vec<1>";
assert_eq!(
locate(
source,
"let ok = a < b && c > d;\nlet v: Vec<1>"
),
None
);
}
#[test]
fn gives_up_when_the_numbers_differ() {
assert_eq!(locate("x // <2>", "x // <b class=\"conum\">(1)</b>"), None);
}
}