use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{CorpusCheck, CorpusView};
use headwater_graph::links::{Binding, Link};
use std::collections::{HashMap, HashSet};
pub const RULE: &str = "link.fragment.unresolved";
const NO_LINKS: &str = "the view carries no bound prose links for this corpus";
const NO_ANCHORS: &str = "the view carries no document headings for this corpus";
pub struct Fragments;
impl CorpusCheck for Fragments {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 4;
const NEEDS_LINKS: bool = true;
const NEEDS_ANCHORS: bool = true;
fn evaluate(&self, view: &CorpusView<'_>) -> Outcome {
let Some(links) = view.links() else {
return Outcome::Skipped(NO_LINKS.to_string());
};
let Some(anchors) = view.anchors() else {
return Outcome::Skipped(NO_ANCHORS.to_string());
};
Outcome::failed(
links
.iter()
.filter_map(|link| finding(link, anchors))
.collect(),
)
}
}
fn finding(link: &Link, anchors: &Anchors) -> Option<Finding> {
let fragment = link.fragment.as_deref().filter(|it| !it.is_empty())?;
let target = match &link.binding {
Binding::SameDocument => link.source_path.as_str(),
Binding::Corpus { path, .. } => path.as_str(),
Binding::Missing { .. }
| Binding::Unnormalizable { .. }
| Binding::Repository { .. }
| Binding::External => return None,
};
if anchors.resolves(target, fragment)? {
return None;
}
let (message, remediation) = match &link.binding {
Binding::SameDocument => (
format!("`{}` names no heading of this document", link.destination),
format!("point it at a heading of {target}, or write the heading it names"),
),
_ => (
format!("`{}` names no heading of `{target}`", link.destination),
format!(
"point it at a heading that `{target}` has, or write the heading it names there"
),
),
};
Some(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: link.source_path.clone(),
line: link.span.start.line,
column: link.span.start.col,
message,
remediation,
patch: None,
})
}
pub struct Anchors {
by_path: Vec<(String, Vec<String>)>,
}
impl Anchors {
pub fn of(census: &headwater_census::census::Census) -> Anchors {
Anchors {
by_path: census
.rows
.iter()
.filter_map(|row| {
let document = row.document.as_ref()?;
Some((row.path.clone(), anchors(&document.body)))
})
.collect(),
}
}
#[cfg(test)]
pub(crate) fn of_pairs(pairs: &[(&str, &[&str])]) -> Anchors {
let mut by_path: Vec<(String, Vec<String>)> = pairs
.iter()
.map(|(path, anchors)| {
(
(*path).to_string(),
anchors.iter().map(|it| (*it).to_string()).collect(),
)
})
.collect();
by_path.sort();
Anchors { by_path }
}
fn resolves(&self, path: &str, fragment: &str) -> Option<bool> {
let at = self
.by_path
.binary_search_by(|(known, _)| known.as_str().cmp(path))
.ok()?;
Some(self.by_path[at].1.iter().any(|it| it == fragment))
}
}
fn anchors(body: &headwater_doc::Body) -> Vec<String> {
let mut anchors: Vec<String> = Vec::new();
let mut issued: HashSet<String> = HashSet::new();
let mut repeats: HashMap<String, usize> = HashMap::new();
for heading in body.headings() {
let slug = slug(&heading.text());
let mut n = repeats.get(&slug).copied().unwrap_or(0);
let anchor = loop {
let candidate = if n == 0 {
slug.clone()
} else {
format!("{slug}-{n}")
};
n += 1;
if !issued.contains(&candidate) {
break candidate;
}
};
repeats.insert(slug, n);
issued.insert(anchor.clone());
anchors.push(anchor);
}
anchors
}
fn slug(text: &str) -> String {
text.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_')
.map(|c| if c == ' ' { '-' } else { c })
.collect()
}
#[cfg(test)]
mod comment_links {
use super::{anchors, slug};
use std::path::{Path, PathBuf};
fn engine_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("the engine root")
}
pub(super) fn repository_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()
.expect("the repository root")
}
use headwater_graph::comments::rust_comment_text as comment_markdown;
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.expect("the engine tree is readable")
.filter_map(Result::ok)
.map(|e| e.path())
.collect();
entries.sort();
for path in entries {
if path.is_dir() {
if path.file_name().is_some_and(|n| n == "target") {
continue;
}
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn anchors_of(path: &Path) -> Vec<String> {
let source = std::fs::read_to_string(path).expect("a document this engine cites");
match headwater_doc::split::split(&source) {
Ok(split) => anchors(&headwater_doc::body::scan(
&source,
split.body,
split.body_offset,
)),
Err(_) => anchors(&headwater_doc::body::scan(&source, &source, 0)),
}
}
fn broken(walk: &Path, root: &Path) -> Vec<String> {
let mut files = Vec::new();
sources(walk, &mut files);
let mut out = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).expect("a source of this engine");
let markdown = comment_markdown(&src);
let body = headwater_doc::body::scan(&markdown, &markdown, 0);
let dir = file.parent().expect("a source has a directory");
for link in &body.links {
if link.image
|| link.destination.contains("://")
|| link.destination.starts_with('/')
{
continue;
}
let (target, fragment) = match link.destination.split_once('#') {
Some((target, fragment)) => (target, Some(fragment)),
None => (link.destination.as_str(), None),
};
if !target.contains("docs/") {
continue;
}
let resolved = normalize(&dir.join(target));
let where_ = format!(
"{}:{}",
file.strip_prefix(root).unwrap_or(&file).display(),
link.span.start.line
);
if !resolved.exists() {
out.push(format!("{where_}: no such file: {}", link.destination));
continue;
}
let Some(fragment) = fragment else { continue };
if resolved.extension().is_some_and(|e| e == "md")
&& !anchors_of(&resolved).iter().any(|it| it == fragment)
{
out.push(format!("{where_}: no such heading: {}", link.destination));
}
}
}
out
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
#[test]
fn every_comment_link_into_docs_resolves() {
let root = repository_root();
let broken = broken(&engine_root(), &root);
assert!(
broken.is_empty(),
"{} comment links into docs/ do not resolve:\n{}",
broken.len(),
broken.join("\n")
);
}
#[test]
fn a_broken_link_in_a_comment_is_named() {
let depth = comment_markdown(
"//! see [spec 2](../../../docs/spec/02-taxonomy-model.md) for the rule\n",
);
let scanned = headwater_doc::body::scan(&depth, &depth, 0);
assert_eq!(scanned.links.len(), 1, "the link is found in the comment");
assert_eq!(
scanned.links[0].destination,
"../../../docs/spec/02-taxonomy-model.md"
);
let quoted = comment_markdown("let s = \"[a](../../../docs/nope.md)\"; // and\n");
let scanned = headwater_doc::body::scan("ed, "ed, 0);
assert!(
scanned.links.is_empty(),
"a docs path inside a string literal is not a link a reader follows"
);
assert_eq!(slug("Q4 — Relation storage"), "q4--relation-storage");
let real = anchors_of(&repository_root().join("docs/spec/09-decisions.md"));
assert!(real.contains(&"q4--relation-storage".to_string()));
assert!(!real.contains(&"the-four-scopes".to_string()));
}
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for Scratch {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for Scratch {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for Scratch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
fn scratch(name: &str) -> Scratch {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("a clock later than the epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"headwater-fragment-{name}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("a scratch directory");
Scratch(dir)
}
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().expect("a file has a directory"))
.expect("a fixture directory");
std::fs::write(path, body).expect("a fixture file");
}
#[test]
fn a_comment_fragment_that_differs_only_in_case_is_named() {
let dir = scratch("comment-case");
write(
&dir.join("docs/spec/02-taxonomy-model.md"),
"# A model\n\nThe body.\n\n## The heading that is here\n\nMore body.\n",
);
write(
&dir.join("engine/crates/check/src/sample.rs"),
concat!(
"//! [exact](../../../../docs/spec/02-taxonomy-model.md#the-heading-that-is-here)\n",
"//! [cased](../../../../docs/spec/02-taxonomy-model.md#The-Heading-That-Is-Here)\n",
),
);
let found = broken(&dir, &dir);
std::fs::remove_dir_all(&dir).ok();
let expected = "engine/crates/check/src/sample.rs:2: no such heading: \
../../../../docs/spec/02-taxonomy-model.md#The-Heading-That-Is-Here";
assert_eq!(found, [expected]);
}
#[test]
fn the_filter_chain_names_what_it_holds_and_passes_what_it_does_not() {
let dir = scratch("filter-chain");
write(
&dir.join("docs/spec/02-taxonomy-model.md"),
"# A model\n\nThe body.\n\n## The heading that is here\n\nMore body.\n",
);
write(
&dir.join("engine/crates/check/src/sample.rs"),
concat!(
"//! [here](../../../../docs/spec/02-taxonomy-model.md#the-heading-that-is-here)\n",
"//! [gone](../../../../docs/spec/99-not-a-document.md)\n",
"//! [retitled](../../../../docs/spec/02-taxonomy-model.md#the-heading-that-is-not)\n",
"//! [rustdoc](../../headwater_doc/struct.Body.html)\n",
"//! [remote](https://example.invalid/docs/spec/02-taxonomy-model.md)\n",
),
);
write(
&dir.join("engine/docs/spec/02-taxonomy-model.md"),
"# A model\n\nThe body.\n\n## The heading that is here\n\nMore body.\n",
);
write(
&dir.join("engine/probe.rs"),
concat!(
"//! [here](docs/spec/02-taxonomy-model.md#the-heading-that-is-here)\n",
"//! [retitled](docs/spec/02-taxonomy-model.md#the-heading-that-is-not)\n",
),
);
let mut found = broken(&dir, &dir);
found.sort();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(
found,
[
"engine/crates/check/src/sample.rs:2: no such file: \
../../../../docs/spec/99-not-a-document.md",
"engine/crates/check/src/sample.rs:3: no such heading: \
../../../../docs/spec/02-taxonomy-model.md#the-heading-that-is-not",
"engine/probe.rs:2: no such heading: \
docs/spec/02-taxonomy-model.md#the-heading-that-is-not",
]
);
}
}
#[cfg(test)]
mod renderer_slug {
use super::comment_links::repository_root;
use super::slug;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const SLUGGERS: &str = r#"
import sys
which = sys.argv[1]
if which == "pymdownx":
from pymdownx.slugs import slugify
fn = slugify(case="lower")
elif which == "default":
from markdown.extensions.toc import slugify as fn
else:
raise SystemExit("no slugger is named " + which)
raw = sys.stdin.buffer.read().decode("utf-8")
texts = raw.split("\0") if raw else []
sys.stdout.buffer.write("\0".join(fn(text, "-") for text in texts).encode("utf-8"))
"#;
struct Population {
headings: Vec<(String, String)>,
documents: usize,
excluded: usize,
}
struct Disagreement {
document: String,
heading: String,
ours: String,
theirs: String,
}
impl Disagreement {
fn report(&self) -> String {
format!(
"{}\n heading `{}`\n engine {}\n renderer {}",
self.document, self.heading, self.ours, self.theirs
)
}
}
fn site_declaration(root: &Path) -> (PathBuf, Vec<String>) {
let source = std::fs::read_to_string(root.join("mkdocs.yml"))
.expect("`mkdocs.yml`, which declares what the site renders");
let mut docs_dir: Option<String> = None;
let mut exclude: Vec<String> = Vec::new();
let mut in_block = false;
for line in source.lines() {
if let Some(rest) = line.strip_prefix("docs_dir:") {
docs_dir = Some(rest.trim().to_string());
}
if in_block {
if line.starts_with(' ') || line.starts_with('\t') {
let pattern = line.trim();
if !pattern.is_empty() && !pattern.starts_with('#') {
exclude.push(pattern.to_string());
}
continue;
}
in_block = false;
}
if line.starts_with("exclude_docs:") {
in_block = true;
}
}
let docs_dir = docs_dir.expect(
"`mkdocs.yml` declares no `docs_dir`, so what the site renders is not stated \
where this reads it",
);
assert!(
!exclude.is_empty(),
"`mkdocs.yml` declares no `exclude_docs` pattern this reads, so this would \
compare files the site never serves"
);
(root.join(docs_dir), exclude)
}
fn is_excluded(relative: &str, patterns: &[String]) -> bool {
patterns.iter().any(|pattern| {
let body = pattern.strip_suffix('/').unwrap_or(pattern);
if body.contains('/') {
return is_excluded_anchored(relative, body, pattern.ends_with('/'));
}
match pattern.strip_suffix('/') {
Some(directory) => relative
.split('/')
.rev()
.skip(1)
.any(|component| component == directory),
None => {
relative == pattern || relative.rsplit('/').next() == Some(pattern.as_str())
}
}
})
}
fn is_excluded_anchored(relative: &str, pattern: &str, directory: bool) -> bool {
let wanted: Vec<&str> = pattern.split('/').collect();
let path: Vec<&str> = relative.split('/').collect();
let fits = if directory {
path.len() > wanted.len()
} else {
path.len() == wanted.len()
};
fits && wanted
.iter()
.zip(&path)
.all(|(want, got)| *want == "*" || want == got)
}
#[test]
fn an_anchored_exclude_docs_pattern_names_one_component_per_star() {
let patterns = vec![
"taxonomies/*/fixtures/".to_string(),
"taxonomies/*/bundle.yml".to_string(),
];
assert!(is_excluded(
"taxonomies/design-spec/fixtures/corpus/a.md",
&patterns
));
assert!(is_excluded("taxonomies/design-spec/bundle.yml", &patterns));
assert!(!is_excluded(
"taxonomies/design-spec/doctrine.md",
&patterns
));
assert!(!is_excluded("taxonomies/README.md", &patterns));
assert!(!is_excluded("spec/fixtures/a.md", &patterns));
assert!(!is_excluded("taxonomies/a/b/fixtures/c.md", &patterns));
}
fn markdown(dir: &Path, out: &mut Vec<PathBuf>) {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
.expect("the directory the site renders")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
entries.sort();
for path in entries {
if path.is_dir() {
markdown(&path, out);
} else if path.extension().is_some_and(|it| it == "md") {
out.push(path);
}
}
}
fn population(root: &Path) -> Population {
let (docs_dir, patterns) = site_declaration(root);
let mut files = Vec::new();
markdown(&docs_dir, &mut files);
let mut headings = Vec::new();
let mut documents = 0usize;
let mut excluded = 0usize;
for file in files {
let relative = file
.strip_prefix(&docs_dir)
.expect("a file this walk found under `docs_dir`")
.to_string_lossy()
.replace('\\', "/");
if is_excluded(&relative, &patterns) {
excluded += 1;
continue;
}
documents += 1;
let reported = file
.strip_prefix(root)
.unwrap_or(&file)
.to_string_lossy()
.replace('\\', "/");
let source = std::fs::read_to_string(&file).expect("a document the site renders");
let body = match headwater_doc::split::split(&source) {
Ok(split) => headwater_doc::body::scan(&source, split.body, split.body_offset),
Err(_) => headwater_doc::body::scan(&source, &source, 0),
};
for heading in body.headings() {
headings.push((reported.clone(), heading.text()));
}
}
Population {
headings,
documents,
excluded,
}
}
fn renderer(which: &str, texts: &[String]) -> Result<Vec<String>, String> {
let mut child = Command::new("python3")
.arg("-c")
.arg(SLUGGERS)
.arg(which)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("python3 did not run: {error}"))?;
let payload = texts.join("\0").into_bytes();
let mut stdin = child.stdin.take().expect("a piped standard input");
let writing = std::thread::spawn(move || stdin.write_all(&payload));
let ran = child
.wait_with_output()
.map_err(|error| format!("python3 did not finish: {error}"))?;
let _ = writing.join();
if !ran.status.success() {
return Err(format!(
"python3 exited {}: {}",
ran.status,
String::from_utf8_lossy(&ran.stderr).trim()
));
}
let text = String::from_utf8(ran.stdout)
.map_err(|error| format!("python3 wrote bytes this cannot read: {error}"))?;
let slugs: Vec<String> = if text.is_empty() {
Vec::new()
} else {
text.split('\0').map(str::to_string).collect()
};
if slugs.len() != texts.len() {
return Err(format!(
"{} headings were sent and {} slugs came back, so no heading here is \
held against the slug that belongs to it",
texts.len(),
slugs.len()
));
}
Ok(slugs)
}
fn disagreements(which: &str, population: &Population) -> Result<Vec<Disagreement>, String> {
let texts: Vec<String> = population
.headings
.iter()
.map(|(_, text)| text.clone())
.collect();
let theirs = renderer(which, &texts)?;
Ok(population
.headings
.iter()
.zip(theirs)
.filter_map(|((document, heading), their)| {
let ours = slug(heading);
(ours != their).then(|| Disagreement {
document: document.clone(),
heading: heading.clone(),
ours,
theirs: their,
})
})
.collect())
}
fn measured(root: &Path) -> Population {
let population = population(root);
assert!(
population.documents > 0,
"no document under `docs_dir` survived the `exclude_docs` patterns, so this \
compared nothing"
);
assert!(
!population.headings.is_empty(),
"{} documents carried no heading between them, so this compared nothing",
population.documents
);
assert!(
population.excluded > 0,
"`exclude_docs` excluded none of the {} files under `docs_dir`, so either the \
declaration in `mkdocs.yml` moved or this stopped reading it, and material \
the site never renders is being held to the site's rule",
population.documents
);
eprintln!(
"note: {} headings across {} rendered documents, {} files excluded by \
`exclude_docs`",
population.headings.len(),
population.documents,
population.excluded
);
population
}
#[test]
fn the_renderer_slugs_every_rendered_heading_as_this_engine_does() {
let required = std::env::var_os("HEADWATER_SLUG_ORACLE").is_some();
let population = measured(&repository_root());
let found = match disagreements("pymdownx", &population) {
Ok(found) => found,
Err(reason) => {
assert!(
!required,
"HEADWATER_SLUG_ORACLE is set and the site's slugger did not run, so \
nothing holds this engine's heading anchors against the ones the site \
serves: {reason}"
);
eprintln!(
"note: the site's slugger did not run, so this slug rule is held only \
against its own cases. Install `pymdown-extensions`, or set \
HEADWATER_SLUG_ORACLE to make its absence a failure.\n{reason}"
);
return;
}
};
assert!(
found.is_empty(),
"the engine's slug rule and the site's disagree on {} of {} rendered \
headings:\n{}",
found.len(),
population.headings.len(),
found
.iter()
.map(Disagreement::report)
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn a_slugger_this_corpus_is_not_rendered_with_is_named_heading_by_heading() {
let required = std::env::var_os("HEADWATER_SLUG_ORACLE").is_some();
let population = measured(&repository_root());
let found = match disagreements("default", &population) {
Ok(found) => found,
Err(reason) => {
assert!(
!required,
"HEADWATER_SLUG_ORACLE is set and Python-Markdown's own slugger did \
not run, so the comparison above has not been seen refusing \
anything: {reason}"
);
eprintln!(
"note: Python-Markdown's slugger did not run, so the comparison above \
has not been seen refusing anything here. Install \
`pymdown-extensions`, or set HEADWATER_SLUG_ORACLE to make its \
absence a failure.\n{reason}"
);
return;
}
};
assert!(
!found.is_empty(),
"Python-Markdown's own slugger agreed with this engine on all {} rendered \
headings, which no corpus of this one's shape does. The comparison has \
stopped comparing, or it is reading a slugger it did not ask for",
population.headings.len()
);
let first = &found[0];
assert!(
first.document.ends_with(".md"),
"a disagreement names no document: {}",
first.report()
);
assert!(
!first.heading.is_empty(),
"a disagreement names no heading: {}",
first.report()
);
assert!(
!first.ours.is_empty() && first.ours != first.theirs,
"a disagreement carries no pair of slugs to compare: {}",
first.report()
);
let report = first.report();
for fact in [
first.document.as_str(),
first.heading.as_str(),
first.ours.as_str(),
first.theirs.as_str(),
] {
assert!(
report.contains(fact),
"the report drops one of the four facts it exists to carry: {report}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_doc::body::scan;
#[test]
fn an_em_dash_leaves_the_spaces_around_it() {
assert_eq!(
slug("Q5 — Voice checking depth"),
"q5--voice-checking-depth"
);
assert_eq!(slug("`$package.optional`"), "packageoptional");
assert_eq!(
slug("Two phases, and why the order matters"),
"two-phases-and-why-the-order-matters"
);
}
fn anchors_of(source: &str) -> Vec<String> {
anchors(&scan(source, source, 0))
}
#[test]
fn a_repeated_heading_takes_a_numeric_suffix() {
let body = scan("# One\n\n# One\n\n# One\n", "# One\n\n# One\n\n# One\n", 0);
assert_eq!(anchors(&body), ["one", "one-1", "one-2"]);
}
#[test]
fn a_longer_heading_is_not_a_repeat_of_the_one_it_extends() {
assert_eq!(
anchors_of("## Edit sites, as spec 2 stands\n\n## Edit sites\n\n## Edit sites\n"),
["edit-sites-as-spec-2-stands", "edit-sites", "edit-sites-1"]
);
}
#[test]
fn a_suffix_an_earlier_heading_took_is_stepped_past() {
assert_eq!(
anchors_of("## One\n\n## One\n\n## One-1\n\n## One\n"),
["one", "one-1", "one-1-1", "one-2"]
);
}
#[test]
fn two_headings_that_differ_never_share_an_anchor() {
let anchors = anchors_of("## One-1\n\n## One\n\n## One\n");
assert_eq!(anchors, ["one-1", "one", "one-2"]);
let issued: std::collections::HashSet<&String> = anchors.iter().collect();
assert_eq!(issued.len(), anchors.len(), "an anchor is issued once");
}
}
#[cfg(test)]
mod arms {
use super::*;
use crate::scope::CorpusView;
use headwater_doc::LinkForm;
use headwater_yaml::{Position, Span};
const CITER: &str = "docs/spec/01-conceptual-model.md";
const TARGET: &str = "docs/spec/glossary.md";
fn span() -> Span {
Span {
start: Position {
line: 30,
col: 5,
offset: 0,
},
end: Position {
line: 30,
col: 6,
offset: 1,
},
}
}
fn link(destination: &str, fragment: Option<&str>, binding: Binding) -> Link {
Link {
source_path: CITER.to_string(),
destination: destination.to_string(),
fragment: fragment.map(str::to_string),
form: LinkForm::Inline,
span: span(),
binding,
}
}
fn corpus(path: &str) -> Binding {
Binding::Corpus {
path: path.to_string(),
class: "typed",
id: None,
}
}
fn index() -> Anchors {
Anchors::of_pairs(&[
(CITER, &["a-heading-of-the-citer"]),
(TARGET, &["projection"]),
])
}
#[test]
fn a_fragment_that_names_no_heading_of_the_target_is_an_error_at_the_citing_line() {
let found = finding(
&link(
"glossary.md#projections",
Some("projections"),
corpus(TARGET),
),
&index(),
)
.expect("a finding");
assert_eq!(found.rule, self::RULE);
assert_eq!(found.severity, Severity::Error);
assert_eq!(found.path, CITER);
assert_eq!(found.line, 30);
assert_eq!(found.column, 5);
assert!(found.message.contains(TARGET), "{found:#?}");
assert!(!found.message.contains("this document"), "{found:#?}");
assert!(!found.fixable(), "{found:#?}");
}
#[test]
fn the_near_arm_says_this_document_and_the_far_arm_names_the_file() {
let near = finding(
&link(
"#no-such-heading",
Some("no-such-heading"),
Binding::SameDocument,
),
&index(),
)
.expect("a finding");
assert!(near.message.contains("this document"), "{near:#?}");
assert!(!near.message.contains(TARGET), "{near:#?}");
assert_eq!(near.path, CITER);
}
#[test]
fn a_fragment_that_resolves_is_not_a_finding_on_either_arm() {
assert!(finding(
&link("glossary.md#projection", Some("projection"), corpus(TARGET)),
&index()
)
.is_none());
assert!(finding(
&link(
"#a-heading-of-the-citer",
Some("a-heading-of-the-citer"),
Binding::SameDocument
),
&index()
)
.is_none());
}
#[test]
fn a_fragment_that_differs_from_its_heading_only_in_case_is_a_finding() {
let across = finding(
&link("glossary.md#Projection", Some("Projection"), corpus(TARGET)),
&index(),
)
.expect("a finding");
assert!(across.message.contains(TARGET), "{across:#?}");
let near = finding(
&link(
"#A-Heading-Of-The-Citer",
Some("A-Heading-Of-The-Citer"),
Binding::SameDocument,
),
&index(),
)
.expect("a finding");
assert!(near.message.contains("this document"), "{near:#?}");
}
#[test]
fn every_binding_that_is_not_this_rule_produces_nothing() {
for binding in [
Binding::Missing {
path: "docs/spec/gone.md".to_string(),
},
Binding::Unnormalizable {
why: "climbs above the repository root".to_string(),
},
Binding::Repository {
path: "CLAUDE.md".to_string(),
},
Binding::External,
] {
assert!(finding(&link("x#y", Some("y"), binding), &index()).is_none());
}
}
#[test]
fn a_link_carrying_no_fragment_is_not_this_rules_business() {
assert!(finding(&link("glossary.md", None, corpus(TARGET)), &index()).is_none());
assert!(finding(&link("glossary.md#", Some(""), corpus(TARGET)), &index()).is_none());
}
#[test]
fn a_corpus_path_with_no_parsed_document_gets_no_verdict_rather_than_a_guess() {
let untyped = "docs/spec/notes.txt";
assert_eq!(index().resolves(untyped, "anything"), None);
assert!(finding(
&link("notes.txt#anything", Some("anything"), corpus(untyped)),
&index()
)
.is_none());
}
#[test]
fn a_view_with_no_links_skips_rather_than_passes() {
let view = CorpusView::only_links_and_anchors(None, None);
assert!(matches!(Fragments.evaluate(&view), Outcome::Skipped(why) if why == NO_LINKS));
}
#[test]
fn a_view_with_links_and_no_anchors_skips_with_the_other_reason() {
let links = vec![link(
"glossary.md#projection",
Some("projection"),
corpus(TARGET),
)];
let view = CorpusView::only_links_and_anchors(Some(&links), None);
assert!(matches!(Fragments.evaluate(&view), Outcome::Skipped(why) if why == NO_ANCHORS));
}
#[test]
fn the_unresolved_fragments_of_the_view_become_the_findings() {
let anchors = index();
let broken = vec![
link(
"glossary.md#projections",
Some("projections"),
corpus(TARGET),
),
link("#nope", Some("nope"), Binding::SameDocument),
link("glossary.md#projection", Some("projection"), corpus(TARGET)),
link("https://example.com", None, Binding::External),
];
let view = CorpusView::only_links_and_anchors(Some(&broken), Some(&anchors));
let Outcome::Failed(found) = Fragments.evaluate(&view) else {
panic!("two unresolved fragments are two findings");
};
assert_eq!(found.len(), 2, "{found:#?}");
let clean = vec![link(
"glossary.md#projection",
Some("projection"),
corpus(TARGET),
)];
let view = CorpusView::only_links_and_anchors(Some(&clean), Some(&anchors));
assert!(matches!(Fragments.evaluate(&view), Outcome::Passed));
}
}