use std::collections::{BTreeMap, HashSet};
use super::{Capture, Checker, Placement, Spot};
use crate::{
Finding,
markdown::{Anchor, Body, render_anchor, slug},
};
pub fn placements(captures: &[Capture]) -> Vec<Placement> {
let mut by_key: BTreeMap<&str, Vec<&Capture>> = BTreeMap::new();
let mut order: Vec<&str> = Vec::new();
for cap in captures {
let entry = by_key.entry(&cap.key).or_default();
if entry.is_empty() {
order.push(&cap.key);
}
entry.push(cap);
}
let mut ids: HashSet<String> = HashSet::new();
let mut out = Vec::new();
for key in order {
let caps = &by_key[key];
let id = slug(key);
if id.is_empty() || ids.contains(&id) {
continue;
}
let spot = match caps.iter().find(|c| c.heading_slug.is_some()) {
Some(h) if h.heading_slug.as_deref() == Some(id.as_str()) => None,
Some(h) => Some(h.spot),
None => caps
.iter()
.find(|c| c.pairs.is_none() && !c.repeats)
.or_else(|| caps.iter().find(|c| c.pairs.is_none()))
.map(|c| c.spot),
};
ids.insert(id.clone());
if let Some(spot) = spot {
out.push(Placement {
id,
key: key.to_string(),
spot,
});
}
}
out
}
pub fn heading_col(body: &Body, text: &str, line: usize) -> usize {
let raw = text.lines().nth(line.saturating_sub(1)).unwrap_or("");
let clean = body.clean_line(line, raw);
let setext = body.headings.iter().any(|h| h.line == line && h.setext);
let lead = clean.len() - clean.trim_start().len();
if setext {
return lead;
}
let after_hashes = clean[lead..].trim_start_matches('#');
let hashes_end = clean.len() - after_hashes.len();
hashes_end + (after_hashes.len() - after_hashes.trim_start().len())
}
fn at_spot(anchor: &Anchor, spot: Spot, body: &Body, text: &str) -> bool {
match spot {
Spot::Heading(line) => {
!anchor.alone && anchor.line == line && anchor.col == heading_col(body, text, line)
}
Spot::Inline(line, col) => !anchor.alone && anchor.line == line && anchor.col == col,
Spot::Before(line) => {
anchor.alone
&& anchor.line < line
&& text
.lines()
.enumerate()
.skip(anchor.line)
.take(line.saturating_sub(anchor.line + 1))
.all(|(i, l)| body.clean_line(i + 1, l).trim().is_empty())
}
}
}
pub(super) fn check(c: &mut Checker<'_>) {
let wanted = placements(&c.out.captures);
let body = c.doc.body;
let text = c.doc.text;
let mut findings = Vec::new();
let mut stale = false;
let mut claimed: HashSet<usize> = HashSet::new();
let fail = |findings: &mut Vec<Finding>, line: usize, message: String| {
findings.push(Finding::error(
c.doc.path.clone(),
Some(line),
"TLMD_109",
message,
));
};
for p in &wanted {
let found: Vec<usize> = (0..body.anchors.len())
.filter(|&i| body.anchors[i].id == p.id)
.collect();
match found
.iter()
.find(|&&i| at_spot(&body.anchors[i], p.spot, body, text))
{
Some(&i) => {
claimed.insert(i);
}
None if found.is_empty() => fail(
&mut findings,
p.spot.line(),
format!(
"key `{}` has no anchor; its repair writes `{}` before it",
p.key,
render_anchor(&p.id)
),
),
None => fail(
&mut findings,
body.anchors[found[0]].line,
format!(
"the anchor `#{}` is not where its key `{}` is; its repair moves it",
p.id, p.key
),
),
}
}
let wanted_ids: HashSet<&str> = wanted.iter().map(|p| p.id.as_str()).collect();
for (i, a) in body.anchors.iter().enumerate() {
if claimed.contains(&i) {
continue;
}
let captured_here = |paired: bool| {
c.out.captures.iter().any(|cap| {
cap.pairs.is_some() == paired && cap.line == a.line && slug(&cap.key) == a.id
})
};
if !wanted_ids.contains(a.id.as_str()) {
if captured_here(true) {
stale = true;
continue;
}
fail(
&mut findings,
a.line,
format!(
"the anchor `#{}` names no key of this document; its repair removes it",
a.id
),
);
} else if body.anchors.iter().filter(|o| o.id == a.id).count() > 1 {
let belongs_here = wanted
.iter()
.any(|p| p.id == a.id && p.spot.line() == a.line);
if captured_here(false) && !belongs_here {
stale = true;
continue;
}
fail(
&mut findings,
a.line,
format!(
"the anchor `#{}` occurs more than once; its repair keeps one",
a.id
),
);
}
}
c.out.anchors_stale = stale || !findings.is_empty();
c.out.anchors = wanted;
if !c.options.scaffold {
c.out.findings.extend(findings);
}
}