use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use crate::block::{self, ElementKind, Syntax, Tree};
use crate::config::{
BarePathPolicy, CodeBlockLanguagePolicy, Config, FragmentAlgorithm, StaleReferencePolicy,
};
use crate::fm::{CountKey, ExceptionEntry, ExceptionLint, Exceptions};
use crate::html;
use crate::span::Span;
use crate::validation::{Diagnostic, Severity};
#[cfg(any(test, feature = "fuzzing"))]
pub fn collect(
tree: &Tree,
rel_path: &Path,
config: &Config,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
exceptions: &Exceptions,
) -> Vec<Diagnostic> {
collect_with_suppressions(
tree,
rel_path,
config,
file_exists,
external_exists,
exceptions,
)
.0
}
pub fn collect_with_suppressions(
tree: &Tree,
rel_path: &Path,
config: &Config,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
exceptions: &Exceptions,
) -> (Vec<Diagnostic>, FileSuppressions) {
let mut diagnostics = Vec::new();
let source = tree.source();
let lookup = ExceptionLookup::new(
exceptions,
&config.artifacts,
config.policy.stale_references != StaleReferencePolicy::Disabled,
config.policy.bare_paths != BarePathPolicy::Disabled,
);
emit_parser_diagnostics(tree, rel_path, &mut diagnostics);
emit_heading_diagnostics(tree, rel_path, config, &mut diagnostics);
emit_tree_bare_paths(
tree,
rel_path,
config,
file_exists,
external_exists,
&lookup,
&mut diagnostics,
);
emit_bare_path_diagnostics(
tree,
rel_path,
config,
file_exists,
external_exists,
&lookup,
&mut diagnostics,
);
emit_html_diagnostics(tree, rel_path, &mut diagnostics);
check_markdown_in_opaque_html(tree, rel_path, &mut diagnostics);
crate::metadata::carrier_diagnostics(tree, rel_path, &mut diagnostics);
emit_code_block_diagnostics(tree, rel_path, config, &mut diagnostics);
emit_image_diagnostics(tree, rel_path, config, &mut diagnostics);
emit_trailing_whitespace_diagnostics(source, rel_path, tree, &mut diagnostics);
lookup.resolve_count_keys(rel_path, &mut diagnostics);
lookup.emit_unmatched(rel_path, &mut diagnostics);
diagnostics.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
let suppressions = lookup.into_suppressions(rel_path);
(diagnostics, suppressions)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SeverityCounts {
pub errors: usize,
pub warnings: usize,
pub info: usize,
pub hints: usize,
}
impl SeverityCounts {
pub fn record(&mut self, severity: Severity) {
match severity {
Severity::Error => self.errors += 1,
Severity::Warning => self.warnings += 1,
Severity::Info => self.info += 1,
Severity::Hint => self.hints += 1,
}
}
pub fn add(&mut self, other: Self) {
self.errors += other.errors;
self.warnings += other.warnings;
self.info += other.info;
self.hints += other.hints;
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.errors == 0 && self.warnings == 0 && self.info == 0 && self.hints == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CountKeySuppression {
pub reason: String,
pub raw: String,
pub counts: SeverityCounts,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExceptionSuppression {
pub counts: SeverityCounts,
pub matched_entries: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileSuppressions {
pub file: std::path::PathBuf,
pub exceptions: Option<ExceptionSuppression>,
pub count_keys: Vec<CountKeySuppression>,
pub artifacts: BTreeMap<String, SeverityCounts>,
}
impl FileSuppressions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.exceptions.is_none() && self.count_keys.is_empty() && self.artifacts.is_empty()
}
}
#[must_use]
pub fn classify_028_lint(message: &str) -> Option<ExceptionLint> {
if message.starts_with("stale reference:") {
Some(ExceptionLint::StaleReferences)
} else if message.starts_with("bare path ")
|| message.starts_with("bare URL ")
|| message.starts_with("quoted path ")
|| message.starts_with("backticked path ")
{
Some(ExceptionLint::BarePaths)
} else {
None
}
}
struct LintBucket<'a> {
active: bool,
entries: &'a [ExceptionEntry],
matched: Vec<Cell<bool>>,
count_key: Option<&'a CountKey>,
residual: RefCell<Vec<Diagnostic>>,
literal_suppressed: RefCell<SeverityCounts>,
count_suppressed: RefCell<SeverityCounts>,
}
impl<'a> LintBucket<'a> {
fn new(entries: &'a [ExceptionEntry], count_key: Option<&'a CountKey>, active: bool) -> Self {
Self {
active,
entries,
matched: entries.iter().map(|_| Cell::new(false)).collect(),
count_key,
residual: RefCell::new(Vec::new()),
literal_suppressed: RefCell::new(SeverityCounts::default()),
count_suppressed: RefCell::new(SeverityCounts::default()),
}
}
fn count_key_active(&self) -> bool {
self.active && self.count_key.is_some()
}
fn suppress_literal(&self, reference: &str, severity: Severity) -> bool {
if !self.active {
return false;
}
let mut suppressed = false;
for (entry, flag) in self.entries.iter().zip(&self.matched) {
if entry.reference == reference {
flag.set(true);
suppressed = true;
}
}
if suppressed {
self.literal_suppressed.borrow_mut().record(severity);
}
suppressed
}
}
struct ExceptionLookup<'a> {
stale_references: LintBucket<'a>,
bare_paths: LintBucket<'a>,
artifacts: &'a BTreeSet<String>,
artifact_suppressed: RefCell<BTreeMap<String, SeverityCounts>>,
}
impl<'a> ExceptionLookup<'a> {
fn new(
exceptions: &'a Exceptions,
artifacts: &'a BTreeSet<String>,
stale_active: bool,
bare_active: bool,
) -> Self {
Self {
stale_references: LintBucket::new(
exceptions.entries(ExceptionLint::StaleReferences),
exceptions.count_key(ExceptionLint::StaleReferences),
stale_active,
),
bare_paths: LintBucket::new(
exceptions.entries(ExceptionLint::BarePaths),
exceptions.count_key(ExceptionLint::BarePaths),
bare_active,
),
artifacts,
artifact_suppressed: RefCell::new(BTreeMap::new()),
}
}
fn bucket(&self, lint: ExceptionLint) -> &LintBucket<'a> {
match lint {
ExceptionLint::StaleReferences => &self.stale_references,
ExceptionLint::BarePaths => &self.bare_paths,
}
}
fn route(
&self,
lint: ExceptionLint,
reference: &str,
diag: Diagnostic,
out: &mut Vec<Diagnostic>,
) {
if self.artifacts.contains(reference) {
self.artifact_suppressed
.borrow_mut()
.entry(reference.to_string())
.or_default()
.record(diag.severity);
return;
}
let bucket = self.bucket(lint);
if bucket.suppress_literal(reference, diag.severity) {
return;
}
if bucket.count_key_active() {
bucket.residual.borrow_mut().push(diag);
} else {
out.push(diag);
}
}
fn resolve_count_keys(&self, rel_path: &Path, out: &mut Vec<Diagnostic>) {
for (lint, bucket) in [
(ExceptionLint::StaleReferences, &self.stale_references),
(ExceptionLint::BarePaths, &self.bare_paths),
] {
let Some(count_key) = bucket.count_key else {
continue;
};
if !bucket.active {
continue;
}
let residual = bucket.residual.take();
let found = residual.len();
let expected = count_key.expected;
if count_key.reason.trim().is_empty() {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: count_key.line,
severity: Severity::Warning,
message: format!(
"count-key `{}` under `exceptions.{}` has no reason — add one explaining why these are not live references (see `lattice help config`)",
count_key.raw,
lint.key()
),
span: Some(count_key.key_span),
});
out.extend(residual);
continue;
}
if expected == 0 {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: count_key.line,
severity: Severity::Warning,
message: format!(
"count-key `{}` under `exceptions.{}` must be at least 1 (see `lattice help config`)",
count_key.raw,
lint.key()
),
span: Some(count_key.key_span),
});
out.extend(residual);
continue;
}
if found == expected {
let mut tally = bucket.count_suppressed.borrow_mut();
for diag in &residual {
tally.record(diag.severity);
}
} else {
out.extend(residual);
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: count_key.line,
severity: Severity::Warning,
message: format!(
"expected {expected} {} here, found {found} — update the count (and revisit the reason) or fix the drift (see `lattice help config`)",
lint.noun()
),
span: Some(count_key.key_span),
});
}
}
}
fn emit_unmatched(&self, rel_path: &Path, out: &mut Vec<Diagnostic>) {
for (lint, bucket) in [
(ExceptionLint::StaleReferences, &self.stale_references),
(ExceptionLint::BarePaths, &self.bare_paths),
] {
if !bucket.active {
continue;
}
for (entry, flag) in bucket.entries.iter().zip(&bucket.matched) {
if entry.reason.trim().is_empty() {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: entry.line,
severity: Severity::Warning,
message: format!(
"exception `{}` under `exceptions.{}` has no reason — add one explaining why this is not a live reference (see `lattice help config`)",
entry.reference,
lint.key()
),
span: Some(entry.key_span),
});
} else if !flag.get() {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: entry.line,
severity: Severity::Warning,
message: format!(
"unused exception: `{}` (reason: \"{}\") — no longer in the document. Drop the exception if its removal was intended; restore the reference if it wasn't (see `lattice help config`)",
entry.reference, entry.reason
),
span: Some(entry.key_span),
});
}
}
}
}
fn into_suppressions(self, rel_path: &Path) -> FileSuppressions {
let mut exception_counts = SeverityCounts::default();
let mut matched_entries = 0;
let mut count_keys = Vec::new();
for bucket in [&self.stale_references, &self.bare_paths] {
exception_counts.add(*bucket.literal_suppressed.borrow());
matched_entries += bucket.matched.iter().filter(|c| c.get()).count();
let count_counts = *bucket.count_suppressed.borrow();
if let Some(count_key) = bucket.count_key
&& !count_counts.is_empty()
{
count_keys.push(CountKeySuppression {
reason: count_key.reason.clone(),
raw: count_key.raw.clone(),
counts: count_counts,
});
}
}
let exceptions = (!exception_counts.is_empty()).then_some(ExceptionSuppression {
counts: exception_counts,
matched_entries,
});
FileSuppressions {
file: rel_path.to_path_buf(),
exceptions,
count_keys,
artifacts: self.artifact_suppressed.into_inner(),
}
}
}
fn emit_parser_diagnostics(tree: &Tree, rel_path: &Path, out: &mut Vec<Diagnostic>) {
let source = tree.source();
for diag in tree.diagnostics() {
let line = block::byte_offset_to_line(source, diag.span.start);
let severity = match diag.level {
block::DiagnosticLevel::Error => Severity::Error,
block::DiagnosticLevel::Warning => Severity::Warning,
};
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity,
message: diag.message.clone(),
span: Some(diag.span),
});
}
}
fn emit_tree_bare_paths(
tree: &Tree,
rel_path: &Path,
config: &Config,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
lookup: &ExceptionLookup,
out: &mut Vec<Diagnostic>,
) {
let bare_paths = tree.bare_paths();
for bare in &bare_paths {
if route_external_reference(
config,
external_exists,
config.policy.stale_references,
rel_path,
bare.line,
None,
&bare.path,
lookup,
out,
)
.is_some()
{
continue;
}
if resolves_under_any_base(rel_path, &bare.path, file_exists) {
if config.policy.bare_paths == BarePathPolicy::Disabled {
continue;
}
let diag = Diagnostic {
file: rel_path.to_path_buf(),
line: bare.line,
severity: bare_path_severity(config.policy.bare_paths, Severity::Warning),
message: format!(
"bare path `{}`: would moving the target update this mention? if so it's a reference — convert to a markdown link; if not it's an example — except it (see `lattice help config`)",
bare.path
),
span: None,
};
lookup.route(ExceptionLint::BarePaths, &bare.path, diag, out);
} else {
route_stale_reference(
config.policy.stale_references,
rel_path,
bare.line,
None,
&bare.path,
lookup,
out,
);
}
}
}
fn emit_heading_diagnostics(
tree: &Tree,
rel_path: &Path,
config: &Config,
out: &mut Vec<Diagnostic>,
) {
let source = tree.source();
let mut prev_level: Option<u8> = None;
let mut h1_count = 0u32;
let mut seen_slugs: HashMap<String, usize> = HashMap::new();
for node in tree.nodes() {
let ElementKind::Heading { level } = &node.kind else {
continue;
};
let level = *level;
let line = block::byte_offset_to_line(source, node.span.start);
let raw = &source[node.span.start..node.span.end];
let text = heading_display_text(raw, node.syntax);
if text.trim().is_empty() {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: "empty heading".to_string(),
span: Some(node.span),
});
prev_level = Some(level);
continue;
}
if config.policy.multiple_h1 && level == 1 {
h1_count += 1;
if h1_count == 2 {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: "multiple H1 headings".to_string(),
span: Some(node.span),
});
}
}
if config.policy.skipped_heading_level
&& let Some(prev) = prev_level
&& level > prev + 1
{
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: format!("skipped heading level: H{prev} to H{level}"),
span: Some(node.span),
});
}
prev_level = Some(level);
let slug = match config.policy.fragments {
Some(FragmentAlgorithm::Github) | None => block::github_slug(&text),
Some(FragmentAlgorithm::Gitlab) => block::gitlab_slug(&text),
Some(FragmentAlgorithm::Vscode) => block::vscode_slug(&text),
};
if let Some(&first_line) = seen_slugs.get(&slug) {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: format!(
"duplicate heading slug `{slug}` (first at line {first_line}) — `#{slug}` resolves only to the first"
),
span: Some(node.span),
});
} else {
seen_slugs.insert(slug, line);
}
}
}
fn heading_display_text(raw: &str, syntax: Syntax) -> String {
if syntax == Syntax::Html {
return block::extract_html_heading_text(raw);
}
let trimmed = raw.trim_start();
if trimmed.starts_with('#') {
let first_line = raw.lines().next().unwrap_or("");
let after_hashes = first_line.trim_start_matches('#');
let content = after_hashes.trim();
let content = content.trim_end_matches('#').trim_end();
if let Some(brace) = content.rfind("{#")
&& content.ends_with('}')
{
return content[..brace].trim().to_string();
}
content.to_string()
} else {
let lines: Vec<&str> = raw.lines().collect();
if lines.len() > 1 {
lines[..lines.len() - 1].join(" ").trim().to_string()
} else {
raw.trim().to_string()
}
}
}
const fn bare_path_severity(policy: BarePathPolicy, base: Severity) -> Severity {
match policy {
BarePathPolicy::Deny => Severity::Error,
_ => base,
}
}
fn build_stale_reference(
policy: StaleReferencePolicy,
rel_path: &Path,
line: usize,
span: Option<Span>,
reference: &str,
) -> Option<Diagnostic> {
let severity = match policy {
StaleReferencePolicy::Disabled => return None,
StaleReferencePolicy::Hint => Severity::Hint,
StaleReferencePolicy::Warn => Severity::Warning,
StaleReferencePolicy::Deny => Severity::Error,
};
Some(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity,
message: format!(
"stale reference: `{reference}` — no such markdown file under this root; would moving the target update this mention? if so it's a reference — fix the path (or write it as `{{repo}}/…` and alias `repo` in .lattice.toml if it's in another repo); if not it's an example — except it (see `lattice help config`)"
),
span,
})
}
fn route_stale_reference(
policy: StaleReferencePolicy,
rel_path: &Path,
line: usize,
span: Option<Span>,
reference: &str,
lookup: &ExceptionLookup,
out: &mut Vec<Diagnostic>,
) {
if let Some(diag) = build_stale_reference(policy, rel_path, line, span, reference) {
lookup.route(ExceptionLint::StaleReferences, reference, diag, out);
}
}
fn build_external_stale_reference(
policy: StaleReferencePolicy,
rel_path: &Path,
line: usize,
span: Option<Span>,
reference: &str,
alias: &str,
config: &Config,
) -> Option<Diagnostic> {
let severity = match policy {
StaleReferencePolicy::Disabled => return None,
StaleReferencePolicy::Hint => Severity::Hint,
StaleReferencePolicy::Warn => Severity::Warning,
StaleReferencePolicy::Deny => Severity::Error,
};
let dir = config
.external
.get(alias)
.map_or_else(String::new, |p| p.display().to_string());
Some(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity,
message: format!(
"stale reference: `{reference}` — external alias `{alias}` resolves to `{dir}`, but no such file or directory exists there; fix the path in that repo (or repoint the `{alias}` alias in .lattice.toml), or except it (see `lattice help config`)"
),
span,
})
}
fn emit_bare_path_diagnostics(
tree: &Tree,
rel_path: &Path,
config: &Config,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
lookup: &ExceptionLookup,
out: &mut Vec<Diagnostic>,
) {
let policy = config.policy.bare_paths;
let stale = config.policy.stale_references;
let source = tree.source();
for node in tree.nodes() {
if !matches!(node.kind, ElementKind::Paragraph | ElementKind::TableCell) {
continue;
}
let excluded: Vec<Span> = node
.children
.iter()
.map(|&child| tree.node(child).span)
.collect();
let text = &source[node.span.start..node.span.end];
let base = node.span.start;
scan_text_for_paths(
text,
base,
source,
rel_path,
policy,
stale,
file_exists,
external_exists,
config,
lookup,
&excluded,
out,
);
for &child_id in &node.children {
let child = tree.node(child_id);
if matches!(child.kind, ElementKind::InlineCode) {
let code_text = &source[child.span.start..child.span.end];
let inner = strip_backtick_delimiters(code_text);
if !looks_like_path(inner) {
continue;
}
let path = split_path_fragment(inner).0;
let line = block::byte_offset_to_line(source, child.span.start);
if route_external_reference(
config,
external_exists,
stale,
rel_path,
line,
Some(child.span),
inner,
lookup,
out,
)
.is_some()
{
continue;
}
if resolves_under_any_base(rel_path, path, file_exists) {
if policy != BarePathPolicy::Disabled {
let diag = Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: bare_path_severity(policy, Severity::Hint),
message: format!(
"backticked path `{inner}` refers to an existing file: would moving it update this mention? if so it's a reference — make it a link; if not it's an example — drop the extension (a name) or except it with a reason (see `lattice help config`)"
),
span: Some(child.span),
};
lookup.route(ExceptionLint::BarePaths, inner, diag, out);
}
} else {
route_stale_reference(
stale,
rel_path,
line,
Some(child.span),
inner,
lookup,
out,
);
}
}
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "scan context parameters are distinct concerns"
)]
fn scan_text_for_paths(
text: &str,
base: usize,
source: &str,
rel_path: &Path,
policy: BarePathPolicy,
stale: StaleReferencePolicy,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
config: &Config,
lookup: &ExceptionLookup,
excluded: &[Span],
out: &mut Vec<Diagnostic>,
) {
for (line_offset, line_text) in text.split('\n').enumerate() {
let line_start = base
+ text
.match_indices('\n')
.take(line_offset)
.last()
.map_or(0, |(i, _)| i + 1);
let line_num = block::byte_offset_to_line(source, line_start);
if policy != BarePathPolicy::Disabled {
scan_line_for_bare_urls(
line_text, line_start, line_num, rel_path, policy, excluded, out,
);
}
scan_line_for_quoted_paths(
line_text,
line_start,
line_num,
rel_path,
policy,
stale,
file_exists,
external_exists,
config,
lookup,
excluded,
out,
);
}
}
fn is_excluded(pos: usize, excluded: &[Span]) -> bool {
excluded.iter().any(|s| pos >= s.start && pos < s.end)
}
fn scan_line_for_bare_urls(
line: &str,
line_start: usize,
line_num: usize,
rel_path: &Path,
policy: BarePathPolicy,
excluded: &[Span],
out: &mut Vec<Diagnostic>,
) {
for prefix in &["https://", "http://"] {
let mut search_start = 0;
while let Some(idx) = line[search_start..].find(prefix) {
let abs_pos = line_start + search_start + idx;
search_start += idx + prefix.len();
if is_excluded(abs_pos, excluded) {
continue;
}
let rest = &line[search_start - prefix.len()..];
let url_end = rest
.find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
.unwrap_or(rest.len());
let url = rest[..url_end].trim_end_matches(['.', ',', ';', ':', '!', '?']);
if url.len() <= prefix.len() {
continue;
}
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: line_num,
severity: bare_path_severity(policy, Severity::Warning),
message: format!(
"bare URL `{url}`: wrap in angle brackets or make a markdown link"
),
span: Some(Span::new(abs_pos, abs_pos + url.len())),
});
}
}
}
fn prev_is_boundary(line: &str, i: usize) -> bool {
line[..i]
.chars()
.next_back()
.is_none_or(|c| c.is_whitespace() || c == '(')
}
fn next_is_alphanumeric(line: &str, i: usize) -> bool {
line[i..].chars().next().is_some_and(char::is_alphanumeric)
}
fn is_quote_delimiter(line: &str, i: usize, quote: u8, opening: bool) -> bool {
if quote == b'"' {
return true;
}
if opening {
prev_is_boundary(line, i)
} else {
!next_is_alphanumeric(line, i + 1)
}
}
fn find_closing_quote(line: &str, from: usize, quote: u8) -> Option<usize> {
let quote_char = char::from(quote);
line[from..].char_indices().find_map(|(off, c)| {
let abs = from + off;
(c == quote_char && is_quote_delimiter(line, abs, quote, false)).then_some(abs)
})
}
#[allow(
clippy::too_many_arguments,
reason = "scan context parameters are distinct concerns"
)]
fn scan_line_for_quoted_paths(
line: &str,
line_start: usize,
line_num: usize,
rel_path: &Path,
policy: BarePathPolicy,
stale: StaleReferencePolicy,
file_exists: &dyn Fn(&Path) -> bool,
external_exists: &dyn Fn(&Path) -> bool,
config: &Config,
lookup: &ExceptionLookup,
excluded: &[Span],
out: &mut Vec<Diagnostic>,
) {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let quote = bytes[i];
if (quote == b'"' || quote == b'\'') && is_quote_delimiter(line, i, quote, true) {
let start = i + 1;
if let Some(end_abs) = find_closing_quote(line, start, quote) {
let inner = &line[start..end_abs];
let abs_pos = line_start + i;
if !is_excluded(abs_pos, excluded) && looks_like_path(inner) {
let span = Span::new(abs_pos, line_start + end_abs + 1);
let path = split_path_fragment(inner).0;
if route_external_reference(
config,
external_exists,
stale,
rel_path,
line_num,
Some(span),
inner,
lookup,
out,
)
.is_some()
{
i = end_abs + 1;
continue;
}
if resolves_under_any_base(rel_path, path, file_exists) {
if policy != BarePathPolicy::Disabled {
let q = char::from(quote);
let diag = Diagnostic {
file: rel_path.to_path_buf(),
line: line_num,
severity: bare_path_severity(policy, Severity::Hint),
message: format!(
"quoted path `{q}{inner}{q}`: would moving the target update this mention? if so it's a reference — make it a markdown link; if not it's an example — except it (see `lattice help config`)"
),
span: Some(span),
};
lookup.route(ExceptionLint::BarePaths, inner, diag, out);
}
} else {
route_stale_reference(
stale,
rel_path,
line_num,
Some(span),
inner,
lookup,
out,
);
}
}
i = end_abs + 1;
} else {
i += 1;
}
} else {
i += 1;
}
}
}
fn strip_backtick_delimiters(s: &str) -> &str {
let bytes = s.as_bytes();
let tick_count = bytes.iter().take_while(|&&b| b == b'`').count();
if tick_count == 0 || s.len() < tick_count * 2 {
return s;
}
let end = s.len() - tick_count;
&s[tick_count..end]
}
fn looks_like_path(s: &str) -> bool {
let path = split_path_fragment(s).0;
!path.is_empty()
&& !path.starts_with("//")
&& !path.starts_with('~')
&& !path.contains(' ')
&& !path.contains('<')
&& !path.contains('>')
&& !path.contains('*')
&& !path.contains('…')
&& !path.contains("...")
&& (path.contains('/') || path.contains('.'))
&& (Path::new(path).extension().is_some_and(|ext| ext == "md")
|| block::external_namespace(path).is_some())
}
fn split_path_fragment(s: &str) -> (&str, Option<&str>) {
match s.split_once('#') {
Some((path, frag)) => (path, Some(frag)),
None => (s, None),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExternalResolution {
Exempt,
Valid,
Stale,
}
fn resolve_external(
config: &Config,
external_exists: &dyn Fn(&Path) -> bool,
alias: &str,
rest: &str,
) -> ExternalResolution {
let Some(dir) = config.external.get(alias) else {
return ExternalResolution::Exempt;
};
if !external_exists(dir) {
return ExternalResolution::Exempt;
}
if external_exists(&dir.join(rest)) {
ExternalResolution::Valid
} else {
ExternalResolution::Stale
}
}
#[allow(
clippy::too_many_arguments,
reason = "routing context parameters are distinct concerns"
)]
fn route_external_reference(
config: &Config,
external_exists: &dyn Fn(&Path) -> bool,
policy: StaleReferencePolicy,
rel_path: &Path,
line: usize,
span: Option<Span>,
reference: &str,
lookup: &ExceptionLookup,
out: &mut Vec<Diagnostic>,
) -> Option<()> {
let path = split_path_fragment(reference).0;
let (alias, rest) = block::external_namespace(path)?;
if resolve_external(config, external_exists, alias, rest) == ExternalResolution::Stale
&& let Some(diag) =
build_external_stale_reference(policy, rel_path, line, span, reference, alias, config)
{
lookup.route(ExceptionLint::StaleReferences, reference, diag, out);
}
Some(())
}
fn resolves_under_any_base(
file_path: &Path,
target: &str,
file_exists: &dyn Fn(&Path) -> bool,
) -> bool {
if let Some(rooted) = target.strip_prefix('/') {
return candidate_exists(Path::new(rooted), file_exists);
}
let dir_relative = file_path
.parent()
.map_or_else(|| std::path::PathBuf::from(target), |dir| dir.join(target));
if candidate_exists(&dir_relative, file_exists) {
return true;
}
candidate_exists(Path::new(target), file_exists)
}
fn candidate_exists(candidate: &Path, file_exists: &dyn Fn(&Path) -> bool) -> bool {
let normalized = block::normalize_path(candidate);
if matches!(
normalized.components().next(),
Some(std::path::Component::ParentDir)
) {
return false;
}
file_exists(&normalized)
}
fn emit_html_diagnostics(tree: &Tree, rel_path: &Path, out: &mut Vec<Diagnostic>) {
let source = tree.source();
let mut seen_ids: HashMap<String, usize> = HashMap::new();
for node in tree.nodes() {
let is_html_node = node.syntax == Syntax::Html;
let is_html_block = matches!(node.kind, ElementKind::HtmlBlock);
if !is_html_node && !is_html_block {
continue;
}
let raw = &source[node.span.start..node.span.end];
let line = block::byte_offset_to_line(source, node.span.start);
let first_line = if is_html_block {
raw.lines().next().unwrap_or("").trim()
} else {
raw.trim()
};
let Some(tag) = html::tokenize_tag(first_line, node.span.start) else {
continue;
};
match tag {
html::HtmlTag::Open {
ref name,
ref attrs,
self_closing,
..
} => {
if self_closing && !html::VOID_ELEMENTS.contains(name.as_str()) {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: format!("self-closing non-void tag `<{name}/>`"),
span: Some(node.span),
});
}
if !html::ALL_ELEMENTS.contains(name.as_str()) {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Info,
message: format!("unknown HTML element `<{name}>`"),
span: Some(node.span),
});
}
for attr in attrs {
if let Some(ref val) = attr.value
&& attr.name == "id"
&& !val.is_empty()
{
if let Some(&first_line) = seen_ids.get(val) {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Error,
message: format!(
"duplicate `id` attribute `{val}` (first at line {first_line})",
),
span: Some(node.span),
});
} else {
seen_ids.insert(val.clone(), line);
}
}
}
check_required_attrs(name, attrs, rel_path, line, out);
check_block_in_inline(tree, node, name, rel_path, line, out);
check_invalid_parent(tree, node, name, rel_path, line, out);
}
html::HtmlTag::Close { .. } | html::HtmlTag::Comment { .. } => {}
}
}
}
fn check_markdown_in_opaque_html(tree: &Tree, rel_path: &Path, out: &mut Vec<Diagnostic>) {
let source = tree.source();
for node in tree.nodes() {
if !matches!(node.kind, ElementKind::HtmlBlock) {
continue;
}
let raw = &source[node.span.start..node.span.end];
let lines: Vec<&str> = raw.lines().collect();
if lines.iter().any(|l| l.trim().is_empty()) {
continue;
}
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if i == 0 || (i == lines.len() - 1 && trimmed.starts_with("</")) {
continue;
}
let has_markdown = trimmed.starts_with('#')
|| trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.contains("](");
if has_markdown {
let line_start = node.span.start
+ raw
.match_indices('\n')
.take(i)
.last()
.map_or(0, |(idx, _)| idx + 1);
let line_num = block::byte_offset_to_line(source, line_start);
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: line_num,
severity: Severity::Warning,
message:
"markdown syntax inside HTML block without blank lines will not be parsed"
.to_string(),
span: None,
});
break;
}
}
}
}
fn check_required_attrs(
tag: &str,
attrs: &[html::Attribute],
rel_path: &Path,
line: usize,
out: &mut Vec<Diagnostic>,
) {
if tag == "a" && attrs.iter().any(|a| a.name == "id" || a.name == "name") {
return;
}
let required: &[&str] = match tag {
"img" => &["alt"],
"a" => &["href"],
_ => return,
};
for &attr_name in required {
if !attrs.iter().any(|a| a.name == attr_name) {
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: format!("`<{tag}>` missing required attribute `{attr_name}`"),
span: None,
});
}
}
}
fn check_block_in_inline(
tree: &Tree,
node: &block::Node,
tag: &str,
rel_path: &Path,
line: usize,
out: &mut Vec<Diagnostic>,
) {
if !html::BLOCK_ELEMENTS.contains(tag) {
return;
}
let mut current = node.parent;
while let Some(pid) = current {
let parent = tree.node(pid);
if parent.syntax == Syntax::Html {
let parent_raw = &tree.source()[parent.span.start..parent.span.end];
let parent_trimmed = parent_raw.trim();
if let Some(html::HtmlTag::Open { ref name, .. }) =
html::tokenize_tag(parent_trimmed, 0)
&& !html::BLOCK_ELEMENTS.contains(name.as_str())
&& !html::VOID_ELEMENTS.contains(name.as_str())
{
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Error,
message: format!("block element `<{tag}>` inside inline element `<{name}>`"),
span: Some(node.span),
});
return;
}
}
current = parent.parent;
}
}
fn check_invalid_parent(
tree: &Tree,
node: &block::Node,
tag: &str,
rel_path: &Path,
line: usize,
out: &mut Vec<Diagnostic>,
) {
let required_parents: &[&str] = match tag {
"tr" | "thead" | "tbody" | "tfoot" | "caption" | "colgroup" | "col" => &["table"],
"td" | "th" => &["table", "tr"],
"li" => &["ul", "ol", "menu"],
"summary" => &["details"],
"option" | "optgroup" => &["select", "datalist"],
_ => return,
};
let mut current = node.parent;
while let Some(pid) = current {
let parent = tree.node(pid);
if parent.syntax == Syntax::Html {
let parent_raw = &tree.source()[parent.span.start..parent.span.end];
let parent_trimmed = parent_raw.trim();
if let Some(html::HtmlTag::Open { ref name, .. }) =
html::tokenize_tag(parent_trimmed, 0)
&& required_parents.contains(&name.as_str())
{
return;
}
}
match &parent.kind {
ElementKind::Table { .. } if required_parents.contains(&"table") => return,
ElementKind::List { ordered: true, .. } if required_parents.contains(&"ol") => return,
ElementKind::List { ordered: false, .. } if required_parents.contains(&"ul") => return,
ElementKind::Details if required_parents.contains(&"details") => return,
_ => {}
}
current = parent.parent;
}
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Error,
message: format!(
"`<{tag}>` requires parent {}",
required_parents
.iter()
.map(|p| format!("`<{p}>`"))
.collect::<Vec<_>>()
.join(" or ")
),
span: Some(node.span),
});
}
fn emit_code_block_diagnostics(
tree: &Tree,
rel_path: &Path,
config: &Config,
out: &mut Vec<Diagnostic>,
) {
let severity = match config.policy.code_block_language {
CodeBlockLanguagePolicy::Disabled => return,
CodeBlockLanguagePolicy::Hint => Severity::Hint,
CodeBlockLanguagePolicy::Warn => Severity::Warning,
CodeBlockLanguagePolicy::Deny => Severity::Error,
};
let source = tree.source();
for node in tree.nodes() {
if !matches!(node.kind, ElementKind::CodeBlock) || node.syntax == Syntax::Html {
continue;
}
let raw = &source[node.span.start..node.span.end];
let first_line = raw.lines().next().unwrap_or("");
let trimmed = first_line.trim();
let is_fenced = trimmed.starts_with("```") || trimmed.starts_with("~~~");
if !is_fenced {
continue;
}
let fence_end = trimmed
.find(|c: char| c != '`' && c != '~')
.unwrap_or(trimmed.len());
let info = trimmed[fence_end..].trim();
if info.is_empty() {
let line = block::byte_offset_to_line(source, node.span.start);
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity,
message:
"code block without a language tag — add one (use `text` for non-code output)"
.to_string(),
span: Some(node.span),
});
}
}
}
fn emit_image_diagnostics(
tree: &Tree,
rel_path: &Path,
config: &Config,
out: &mut Vec<Diagnostic>,
) {
if !config.policy.image_empty_alt {
return;
}
let source = tree.source();
for node in tree.nodes() {
if !matches!(
&node.kind,
ElementKind::Image { .. } | ElementKind::Video { .. } | ElementKind::Audio { .. }
) {
continue;
}
let raw = &source[node.span.start..node.span.end];
if node.syntax == Syntax::Markdown
&& raw.starts_with("
{
let alt = &raw[2..close];
if alt.trim().is_empty() {
let line = block::byte_offset_to_line(source, node.span.start);
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line,
severity: Severity::Warning,
message: "image with empty alt text".to_string(),
span: Some(node.span),
});
}
}
}
}
fn emit_trailing_whitespace_diagnostics(
source: &str,
rel_path: &Path,
tree: &Tree,
out: &mut Vec<Diagnostic>,
) {
let excluded: Vec<Span> = tree
.nodes()
.iter()
.filter(|n| {
matches!(
n.kind,
ElementKind::CodeBlock | ElementKind::HtmlBlock | ElementKind::Math
)
})
.map(|n| n.span)
.collect();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx + 1;
let line_start = source
.match_indices('\n')
.take(line_idx)
.last()
.map_or(0, |(i, _)| i + 1);
if excluded
.iter()
.any(|s| line_start >= s.start && line_start < s.end)
{
continue;
}
let trailing = line.len() - line.trim_end_matches(' ').len();
if trailing == 1 || trailing >= 3 {
let line_end = line_start + line.len();
out.push(Diagnostic {
file: rel_path.to_path_buf(),
line: line_num,
severity: Severity::Warning,
message: format!(
"invalid trailing whitespace ({trailing} spaces): use 2 for hard break or 0"
),
span: Some(Span::new(line_end - trailing, line_end)),
});
}
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::panic,
reason = "tests use expect and panic for clarity"
)]
mod tests {
use std::collections::HashSet;
use super::*;
use crate::block;
use crate::config::Config;
use crate::fm;
use crate::yaml;
fn diagnose(content: &str) -> Vec<Diagnostic> {
let config = Config::default();
diagnose_with_config(content, &config)
}
fn exceptions_of(content: &str) -> Exceptions {
yaml::parse_frontmatter_block(content)
.map(|block| fm::extract_exceptions(&block, content))
.unwrap_or_default()
}
fn diagnose_with_config(content: &str, config: &Config) -> Vec<Diagnostic> {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let rel_path = std::path::Path::new("test.md");
let exceptions = exceptions_of(content);
collect(&tree, rel_path, config, &|_| false, &|_| false, &exceptions)
}
fn diagnose_with_external(
content: &str,
config: &Config,
external_present: &[&str],
) -> Vec<Diagnostic> {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let rel_path = std::path::Path::new("test.md");
let present: HashSet<&str> = external_present.iter().copied().collect();
let exceptions = exceptions_of(content);
collect(
&tree,
rel_path,
config,
&|_| false,
&|p| present.contains(p.to_str().unwrap_or("")),
&exceptions,
)
}
fn diagnose_with_files(content: &str, existing: &[&str]) -> Vec<Diagnostic> {
diagnose_at_path_with_files("test.md", content, existing)
}
fn diagnose_at_path_with_files(
rel_path: &str,
content: &str,
existing: &[&str],
) -> Vec<Diagnostic> {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let config = Config::default();
let rel_path = std::path::Path::new(rel_path);
let existing_set: HashSet<&str> = existing.iter().copied().collect();
let exceptions = exceptions_of(content);
collect(
&tree,
rel_path,
&config,
&|p| existing_set.contains(p.to_str().unwrap_or("")),
&|_| false,
&exceptions,
)
}
fn count_matching(diags: &[Diagnostic], severity: Severity, substr: &str) -> usize {
diags
.iter()
.filter(|d| d.severity == severity && d.message.contains(substr))
.count()
}
fn has_matching(diags: &[Diagnostic], severity: Severity, substr: &str) -> bool {
diags
.iter()
.any(|d| d.severity == severity && d.message.contains(substr))
}
fn has_any(diags: &[Diagnostic], substr: &str) -> bool {
diags.iter().any(|d| d.message.contains(substr))
}
#[test]
fn unclosed_fenced_code_block() {
let diags = diagnose("```rust\nfn main() {}\n");
assert_eq!(
count_matching(&diags, Severity::Error, "unclosed fenced code block"),
1,
"one error for unclosed code block: {diags:?}"
);
}
#[test]
fn closed_code_block_no_error() {
let diags = diagnose("```rust\nfn main() {}\n```\n");
assert!(
!has_matching(&diags, Severity::Error, "unclosed"),
"no errors for closed code block: {diags:?}"
);
}
#[test]
fn unclosed_html_tag() {
let diags = diagnose("<div>\n\nSome content\n");
assert_eq!(
count_matching(&diags, Severity::Error, "unclosed"),
1,
"one error for unclosed div: {diags:?}"
);
}
#[test]
fn unexpected_close_tag() {
let diags = diagnose("</div>\n");
assert_eq!(
count_matching(&diags, Severity::Error, "unexpected closing tag"),
1,
"one error for unexpected close: {diags:?}"
);
}
#[test]
fn skipped_heading_level_silent_by_default() {
let diags = diagnose("# H1\n\n### H3\n");
assert!(
!has_any(&diags, "skipped heading level"),
"no skipped-level warning by default: {diags:?}"
);
}
#[test]
fn skipped_heading_level_fires_when_enabled() {
let mut config = Config::default();
config.policy.skipped_heading_level = true;
let diags = diagnose_with_config("# H1\n\n### H3\n", &config);
assert_eq!(
count_matching(&diags, Severity::Warning, "skipped heading level"),
1,
"one warning for skipped heading when enabled: {diags:?}"
);
assert!(
has_any(&diags, "H1 to H3"),
"message mentions levels: {diags:?}"
);
}
#[test]
fn multiple_h1_silent_by_default() {
let diags = diagnose("# First\n\n# Second\n");
assert!(
!has_any(&diags, "multiple H1"),
"no multiple-H1 warning by default: {diags:?}"
);
}
#[test]
fn multiple_h1_fires_when_enabled() {
let mut config = Config::default();
config.policy.multiple_h1 = true;
let diags = diagnose_with_config("# First\n\n# Second\n", &config);
assert_eq!(
count_matching(&diags, Severity::Warning, "multiple H1"),
1,
"one warning for multiple H1 when enabled: {diags:?}"
);
}
#[test]
fn duplicate_heading_exact() {
let diags = diagnose("## Overview\n\n## Overview\n");
assert_eq!(
count_matching(
&diags,
Severity::Warning,
"duplicate heading slug `overview`"
),
1,
"one warning for exact duplicate heading: {diags:?}"
);
}
#[test]
fn duplicate_heading_punctuation_collision() {
let diags = diagnose("# Hello, World\n\n# Hello World\n");
assert_eq!(
count_matching(
&diags,
Severity::Warning,
"duplicate heading slug `hello-world`"
),
1,
"one warning for punctuation/spacing slug collision: {diags:?}"
);
}
#[test]
fn distinct_heading_slugs_no_duplicate() {
let diags = diagnose("## Overview\n\n## Details\n");
assert!(
!has_any(&diags, "duplicate heading slug"),
"no duplicate warning for distinct slugs: {diags:?}"
);
}
#[test]
fn empty_heading() {
let diags = diagnose("# \n");
assert_eq!(
count_matching(&diags, Severity::Warning, "empty heading"),
1,
"one warning for empty heading: {diags:?}"
);
}
#[test]
fn sequential_headings_no_warning() {
let mut config = Config::default();
config.policy.skipped_heading_level = true;
let diags = diagnose_with_config("# H1\n\n## H2\n\n### H3\n", &config);
assert!(
!has_matching(&diags, Severity::Warning, "skipped"),
"no warnings for sequential headings: {diags:?}"
);
}
#[test]
fn code_block_without_language_silent_by_default() {
let diags = diagnose("```\ncode\n```\n");
assert!(
!has_any(&diags, "language tag"),
"no missing-language diagnostic by default: {diags:?}"
);
}
#[test]
fn code_block_without_language_fires_when_enabled() {
for (policy, severity) in [
(CodeBlockLanguagePolicy::Hint, Severity::Hint),
(CodeBlockLanguagePolicy::Warn, Severity::Warning),
(CodeBlockLanguagePolicy::Deny, Severity::Error),
] {
let mut config = Config::default();
config.policy.code_block_language = policy;
let diags = diagnose_with_config("```\ncode\n```\n", &config);
assert_eq!(
count_matching(&diags, severity, "without a language tag"),
1,
"one {policy:?} diagnostic for missing language: {diags:?}"
);
}
let mut config = Config::default();
config.policy.code_block_language = CodeBlockLanguagePolicy::Hint;
let diags = diagnose_with_config("```\ncode\n```\n", &config);
assert!(
has_matching(&diags, Severity::Hint, "`text`"),
"missing-language hint should point at the `text` escape hatch: {diags:?}"
);
}
#[test]
fn code_block_with_language_no_diagnostic() {
let diags = diagnose("```rust\ncode\n```\n");
assert!(
!has_any(&diags, "language tag"),
"no hint for code block with language: {diags:?}"
);
}
#[test]
fn image_empty_alt_text_silent_by_default() {
let diags = diagnose("\n");
assert!(
!has_any(&diags, "empty alt text"),
"no empty-alt warning by default: {diags:?}"
);
}
#[test]
fn image_empty_alt_text_fires_when_enabled() {
let mut config = Config::default();
config.policy.image_empty_alt = true;
let diags = diagnose_with_config("\n", &config);
assert_eq!(
count_matching(&diags, Severity::Warning, "empty alt text"),
1,
"one warning for empty alt when enabled: {diags:?}"
);
}
#[test]
fn image_with_alt_text_no_diagnostic() {
let mut config = Config::default();
config.policy.image_empty_alt = true;
let diags = diagnose_with_config("\n", &config);
assert!(
!has_any(&diags, "empty alt text"),
"no warning for image with alt: {diags:?}"
);
}
#[test]
fn anchor_with_id_no_href_no_warning() {
let diags = diagnose("<a id=\"a\"></a>\n");
assert!(
!has_any(&diags, "missing required attribute `href`"),
"no missing-href warning for an `<a id>` anchor target: {diags:?}"
);
}
#[test]
fn anchor_with_name_no_href_no_warning() {
let diags = diagnose("<a name=\"a\"></a>\n");
assert!(
!has_any(&diags, "missing required attribute `href`"),
"no missing-href warning for an `<a name>` anchor target: {diags:?}"
);
}
#[test]
fn anchor_without_href_or_anchor_attr_still_warns() {
let diags = diagnose("<a class=\"x\"></a>\n");
assert_eq!(
count_matching(
&diags,
Severity::Warning,
"missing required attribute `href`"
),
1,
"an `<a>` with no href and no id/name still warns: {diags:?}"
);
}
#[test]
fn anchor_with_href_no_warning() {
let diags = diagnose("<a href=\"https://example.com\">x</a>\n");
assert!(
!has_any(&diags, "missing required attribute `href`"),
"no missing-href warning for a normal linking `<a href>`: {diags:?}"
);
}
#[test]
fn single_trailing_space() {
let diags = diagnose("hello \n");
assert_eq!(
count_matching(&diags, Severity::Warning, "trailing whitespace"),
1,
"one warning for 1 trailing space: {diags:?}"
);
}
#[test]
fn two_trailing_spaces_ok() {
let diags = diagnose("hello \n");
assert!(
!has_any(&diags, "trailing whitespace"),
"no warning for 2 trailing spaces: {diags:?}"
);
}
#[test]
fn three_trailing_spaces() {
let diags = diagnose("hello \n");
assert_eq!(
count_matching(&diags, Severity::Warning, "trailing whitespace"),
1,
"one warning for 3 trailing spaces: {diags:?}"
);
}
#[test]
fn trailing_whitespace_in_code_block_excluded() {
let diags = diagnose("```\nhello \n```\n");
assert!(
!has_any(&diags, "trailing whitespace"),
"no warning for trailing spaces inside code: {diags:?}"
);
}
#[test]
fn bare_url_in_paragraph() {
let diags = diagnose("Visit https://example.com for info.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "bare URL"),
1,
"one warning for bare URL: {diags:?}"
);
}
#[test]
fn bare_url_trailing_punctuation_excluded() {
let diags = diagnose("See https://example.com, then continue.\n");
assert!(
has_matching(&diags, Severity::Warning, "bare URL `https://example.com`"),
"trailing comma excluded from the reported URL: {diags:?}"
);
assert!(
!has_any(&diags, "https://example.com,"),
"reported URL must not include the trailing comma: {diags:?}"
);
}
#[test]
fn bare_url_past_line_midpoint_no_panic() {
let diags =
diagnose("A long line of filler text before the link, then https://example.com\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "bare URL"),
1,
"one warning for bare URL past line midpoint: {diags:?}"
);
}
#[test]
fn bare_url_diagnostic_has_precise_span() {
let content = "Visit https://example.com for info.\n";
let diags = diagnose(content);
let d = diags
.iter()
.find(|d| d.message.contains("bare URL"))
.expect("a bare URL diagnostic");
let span = d.span.expect("bare URL diagnostic carries a span");
assert_eq!(
&content[span.start..span.end],
"https://example.com",
"span underlines exactly the URL: {diags:?}"
);
}
#[test]
fn trailing_whitespace_diagnostic_spans_the_spaces() {
let content = "hello \nworld\n";
let diags = diagnose(content);
let d = diags
.iter()
.find(|d| d.message.contains("trailing whitespace"))
.expect("a trailing whitespace diagnostic");
let span = d
.span
.expect("trailing whitespace diagnostic carries a span");
assert_eq!(
&content[span.start..span.end],
" ",
"span covers exactly the three trailing spaces: {diags:?}"
);
}
#[test]
fn unclosed_html_no_cascade_to_valid_content() {
let diags = diagnose("<div>\n\n# Valid Heading\n\nSome paragraph.\n");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert_eq!(errors.len(), 1, "only one error, no cascading: {diags:?}");
assert!(
errors[0].message.contains("unclosed"),
"the error is about unclosed tag: {}",
errors[0].message
);
}
#[test]
fn quoted_path_with_existing_file() {
let diags = diagnose_with_files("See \"other.md\" for details.\n", &["other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "quoted path"),
1,
"one hint for quoted path: {diags:?}"
);
}
#[test]
fn backticked_path_with_existing_file() {
let diags = diagnose_with_files("See `other.md` for details.\n", &["other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"one hint for backticked path: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "make it a link"),
"the hint offers the make-it-a-link resolution: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "drop the extension"),
"the hint offers the drop-the-extension resolution for a name: {diags:?}"
);
}
#[test]
fn backticked_path_no_file() {
let diags = diagnose("See `other.md` for details.\n");
assert!(
!has_any(&diags, "backticked path"),
"no make-it-a-link hint when file doesn't exist: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling backtick `.md` draws the stale-reference warning: {diags:?}"
);
}
#[test]
fn quoted_path_no_file_is_stale_reference() {
let diags = diagnose("See \"other.md\" for details.\n");
assert!(
!has_any(&diags, "quoted path"),
"no make-it-a-link hint for a dangling quoted path: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling quoted `.md` draws the stale-reference warning: {diags:?}"
);
}
#[test]
fn quoted_dir_path_dangling_emits_one_stale() {
let diags = diagnose("See \"docs/gone.md\" for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling quoted dir-bearing `.md` is stale exactly once: {diags:?}"
);
}
#[test]
fn quoted_external_dir_path_dangling_emits_one_stale() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See \"{Catenary}/gone.md\" for details.\n",
&config,
&["/ext/Catenary"],
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a quoted external dir-bearing `.md` is stale exactly once: {diags:?}"
);
}
#[test]
fn quoted_dir_path_resolving_emits_one_make_it_a_link() {
let diags = diagnose_with_files("See \"docs/other.md\" for details.\n", &["docs/other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "quoted path"),
1,
"a resolving quoted dir-bearing path draws one make-it-a-link hint: {diags:?}"
);
assert!(
!has_any(&diags, "convert to a markdown link"),
"the bare-path nudge does not also fire on quoted content: {diags:?}"
);
}
#[test]
fn two_distinct_quoted_dir_paths_each_emit_once() {
let diags = diagnose("See \"docs/a.md\" and \"docs/b.md\" for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
2,
"two distinct quoted dir-bearing paths each emit one stale: {diags:?}"
);
}
#[test]
fn single_quoted_dangling_path_emits_one_stale() {
let diags = diagnose("See 'docs/gone.md' for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling single-quoted `.md` is stale exactly once: {diags:?}"
);
}
#[test]
fn single_quoted_resolving_path_emits_one_make_it_a_link() {
let diags = diagnose_with_files("See 'docs/other.md' for details.\n", &["docs/other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "quoted path"),
1,
"a resolving single-quoted path draws one make-it-a-link hint: {diags:?}"
);
assert!(
has_any(&diags, "`'docs/other.md'`"),
"the hint reflects the single-quote character: {diags:?}"
);
}
#[test]
fn double_quoted_dangling_path_still_one_stale_no_regression() {
let diags = diagnose("See \"docs/gone.md\" for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling double-quoted `.md` is still stale exactly once: {diags:?}"
);
}
#[test]
fn apostrophe_not_treated_as_quote() {
for content in ["it's a test\n", "the dogs' bowls\n", "rock 'n' roll\n"] {
let diags = diagnose(content);
assert!(
!has_any(&diags, "quoted path") && !has_any(&diags, "stale reference"),
"an apostrophe is not a quote delimiter in {content:?}: {diags:?}"
);
}
}
#[test]
fn opening_single_quote_requires_whitespace_before() {
let glued = diagnose("set value_'docs/gone.md' now\n");
assert!(
!has_any(&glued, "stale reference") && !has_any(&glued, "quoted path"),
"a non-whitespace-preceded `'` must not open a quoted span: {glued:?}"
);
let prose = diagnose("the function example_'s parameters' types are typed\n");
assert!(
!has_any(&prose, "stale reference") && !has_any(&prose, "quoted path"),
"apostrophe-heavy prose opens no quoted span: {prose:?}"
);
}
#[test]
fn paren_opens_single_quote_but_bracket_does_not() {
let paren = diagnose("see the example ('docs/gone.md') here\n");
assert_eq!(
count_matching(&paren, Severity::Warning, "stale reference"),
1,
"a `(`-preceded `'` opens a quoted path: {paren:?}"
);
let bracket = diagnose("see the example ['docs/gone.md'] here\n");
assert!(
!has_any(&bracket, "stale reference") && !has_any(&bracket, "quoted path"),
"a `[`-preceded `'` does not open (markdown link clash): {bracket:?}"
);
}
#[test]
fn contraction_before_single_quoted_path_is_caught() {
let diags = diagnose("it's in 'docs/gone.md' today\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a contraction before a single-quoted path does not hide it: {diags:?}"
);
}
#[test]
fn multibyte_before_single_quote_is_caught_no_panic() {
let diags = diagnose("café 'docs/gone.md'\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a multibyte char before a single-quoted path: caught, no panic: {diags:?}"
);
}
#[test]
fn single_quoted_external_dir_path_dangling_emits_one_stale() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See '{Catenary}/gone.md' for details.\n",
&config,
&["/ext/Catenary"],
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a single-quoted external dir-bearing `.md` is stale exactly once: {diags:?}"
);
}
#[test]
fn two_distinct_single_quoted_paths_each_emit_once() {
let diags = diagnose("See 'docs/a.md' and 'docs/b.md' for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
2,
"two distinct single-quoted dir-bearing paths each emit one stale: {diags:?}"
);
}
#[test]
fn mixed_quote_styles_with_multibyte_each_emit_once() {
let diags = diagnose("See \"docs/other.md\" and 'docs/外部.md' for café details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
2,
"one double- and one single-quoted path each emit one stale: {diags:?}"
);
}
#[test]
fn bare_path_no_file_is_stale_reference() {
let diags = diagnose("See docs/other.md for details.\n");
assert!(
!has_any(&diags, "convert to a markdown link"),
"no make-it-a-link nudge for a dangling bare path: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a dangling bare `.md` draws the stale-reference warning: {diags:?}"
);
}
#[test]
fn bare_path_existing_file_is_make_it_a_link() {
let diags = diagnose_with_files("See docs/other.md for details.\n", &["docs/other.md"]);
assert_eq!(
count_matching(&diags, Severity::Warning, "convert to a markdown link"),
1,
"a resolving bare path keeps the make-it-a-link nudge: {diags:?}"
);
assert!(
!has_any(&diags, "stale reference"),
"a resolving bare path draws no stale-reference warning: {diags:?}"
);
}
#[test]
fn backticked_fragment_existing_file_make_it_a_link() {
let diags = diagnose_with_files("See `other.md#intro` for details.\n", &["other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"an anchored backtick path resolves the file (fragment stripped): {diags:?}"
);
}
#[test]
fn backticked_fragment_missing_file_is_stale_reference() {
let diags = diagnose("See `other.md#intro` for details.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"an anchored backtick to a missing file is stale: {diags:?}"
);
}
#[test]
fn quoted_fragment_existing_file_make_it_a_link() {
let diags = diagnose_with_files("See \"other.md#intro\" for details.\n", &["other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "quoted path"),
1,
"an anchored quoted path resolves the file (fragment stripped): {diags:?}"
);
}
#[test]
fn non_md_extensions_draw_no_dark_matter() {
for path in ["src/main.rs", "Cargo.toml", "docs/logo.png"] {
let backtick = format!("See `{path}` for details.\n");
let resolving = diagnose_with_files(&backtick, &[path]);
let dangling = diagnose(&backtick);
for diags in [&resolving, &dangling] {
assert!(
!has_any(diags, "backticked path")
&& !has_any(diags, "stale reference")
&& !has_any(diags, "convert to a markdown link"),
"non-`.md` path `{path}` draws no dark-matter diagnostic: {diags:?}"
);
}
}
}
#[test]
fn stem_without_extension_is_silent() {
for stem in ["README", "docs/README"] {
let diags = diagnose_with_files(&format!("See `{stem}` for details.\n"), &[stem]);
assert!(
!has_any(&diags, "backticked path")
&& !has_any(&diags, "stale reference")
&& !has_any(&diags, "convert to a markdown link"),
"a bare stem `{stem}` is silent: {diags:?}"
);
}
}
#[test]
fn file_line_syntax_is_silent() {
let diags = diagnose("See docs/foo.md:102 for details.\n");
assert!(
!has_any(&diags, "stale reference")
&& !has_any(&diags, "convert to a markdown link")
&& !has_any(&diags, "backticked path"),
"`file:line` syntax is not a reference form: {diags:?}"
);
}
#[test]
fn root_relative_existing_file_make_it_a_link() {
let diags = diagnose_at_path_with_files("a/b/c.md", "See `/README.md`.\n", &["README.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"root-relative `.md` resolves at the workspace root: {diags:?}"
);
assert!(
!has_any(&diags, "stale reference"),
"a resolving root-relative path draws no stale-reference: {diags:?}"
);
}
fn diagnose_with_stale_policy(
content: &str,
existing: &[&str],
stale: StaleReferencePolicy,
) -> Vec<Diagnostic> {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let mut config = Config::default();
config.policy.stale_references = stale;
let rel_path = std::path::Path::new("test.md");
let existing_set: HashSet<&str> = existing.iter().copied().collect();
let exceptions = exceptions_of(content);
collect(
&tree,
rel_path,
&config,
&|p| existing_set.contains(p.to_str().unwrap_or("")),
&|_| false,
&exceptions,
)
}
#[test]
fn stale_references_disabled_silences_only_the_stale_warning() {
let dangling =
diagnose_with_stale_policy("See `gone.md`.\n", &[], StaleReferencePolicy::Disabled);
assert!(
!has_any(&dangling, "stale reference"),
"disabled silences the stale-reference warning: {dangling:?}"
);
let resolving = diagnose_with_stale_policy(
"See `other.md`.\n",
&["other.md"],
StaleReferencePolicy::Disabled,
);
assert_eq!(
count_matching(&resolving, Severity::Hint, "backticked path"),
1,
"disabling stale_references leaves the make-it-a-link hint intact: {resolving:?}"
);
}
#[test]
fn stale_references_deny_is_error() {
let diags = diagnose_with_stale_policy("See `gone.md`.\n", &[], StaleReferencePolicy::Deny);
assert_eq!(
count_matching(&diags, Severity::Error, "stale reference"),
1,
"deny escalates the stale-reference to an error: {diags:?}"
);
}
#[test]
fn stale_references_hint_is_hint() {
let diags = diagnose_with_stale_policy("See `gone.md`.\n", &[], StaleReferencePolicy::Hint);
assert_eq!(
count_matching(&diags, Severity::Hint, "stale reference"),
1,
"hint downgrades the stale-reference to a hint: {diags:?}"
);
}
#[test]
fn stale_reference_fires_even_when_bare_paths_disabled() {
let fm = yaml::parse_frontmatter_block("See `gone.md`.\n");
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree("See `gone.md`.\n", fm_span);
let mut config = Config::default();
config.policy.bare_paths = BarePathPolicy::Disabled;
let rel_path = std::path::Path::new("test.md");
let diags = collect(
&tree,
rel_path,
&config,
&|_| false,
&|_| false,
&Exceptions::default(),
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"stale_references is independent of bare_paths: {diags:?}"
);
}
#[test]
fn backticked_root_relative_path_resolves_at_workspace_root() {
let diags = diagnose_at_path_with_files(
"a/b/c.md",
"See `/README.md` for details.\n",
&["README.md"],
);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"root-relative backticked path resolves at the workspace root: {diags:?}"
);
}
#[test]
fn backticked_root_relative_resolution_independent_of_depth() {
let root = diagnose_at_path_with_files("root.md", "See `/README.md`.\n", &["README.md"]);
let deep =
diagnose_at_path_with_files("a/b/c/d/deep.md", "See `/README.md`.\n", &["README.md"]);
assert_eq!(
count_matching(&root, Severity::Hint, "backticked path"),
count_matching(&deep, Severity::Hint, "backticked path"),
"root-relative resolution is depth-independent: root={root:?} deep={deep:?}"
);
assert_eq!(
count_matching(&deep, Severity::Hint, "backticked path"),
1,
"the deep reference still resolves at the workspace root: {deep:?}"
);
}
#[test]
fn backticked_root_relative_missing_file_no_hint() {
let diags = diagnose_at_path_with_files(
"a/b/c.md",
"See `/nope.md` for details.\n",
&["README.md"],
);
assert!(
!has_any(&diags, "backticked path"),
"no make-it-a-link hint for a missing root-relative target: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a missing root-relative `.md` draws the stale-reference warning: {diags:?}"
);
}
#[test]
fn protocol_relative_backticked_path_not_treated_as_workspace_path() {
let diags = diagnose_at_path_with_files(
"a/b/c.md",
"See `//cdn.example.com/lib.md` for details.\n",
&["cdn.example.com/lib.md", "lib.md"],
);
assert!(
!has_any(&diags, "backticked path"),
"protocol-relative `//host` is external, not a workspace path: {diags:?}"
);
}
#[test]
fn dir_relative_dotdot_is_normalized_no_stale() {
let diags = diagnose_at_path_with_files(
"architecture/catenary/Hook.md",
"See `../claude_code/PostToolUse.md` for details.\n",
&["architecture/claude_code/PostToolUse.md"],
);
assert!(
!has_any(&diags, "stale reference"),
"a `..`-relative reference that resolves after normalization is not stale: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"the normalized dir-relative reference draws the make-it-a-link hint: {diags:?}"
);
}
#[test]
fn repo_root_relative_citation_resolves_at_root_no_stale() {
let diags = diagnose_at_path_with_files(
"tickets/acquire/v2_01_cleanup.md",
"See `tickets/acquire/DESIGN.md` for details.\n",
&["tickets/acquire/DESIGN.md"],
);
assert!(
!has_any(&diags, "stale reference"),
"a repo-root-relative citation that exists at root is not stale: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"the root-resolved citation draws the make-it-a-link hint: {diags:?}"
);
}
#[test]
fn genuine_dangling_under_neither_base_is_stale() {
let diags = diagnose_at_path_with_files(
"tickets/x/note.md",
"See `tickets/correlation/missing.md` for details.\n",
&["tickets/acquire/DESIGN.md"],
);
assert!(
!has_any(&diags, "backticked path"),
"a reference resolving under no base draws no make-it-a-link hint: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a reference resolving under neither base is a genuine stale reference: {diags:?}"
);
}
#[test]
fn excluded_path_shapes_draw_no_diagnostic() {
for token in [
"~/Projects/Catenary/AGENTS.md",
"<name>/SKILL.md",
"NN_*.md",
] {
let backtick = format!("See `{token}` for details.\n");
let dangling = diagnose(&backtick);
let with_file = diagnose_with_files(&backtick, &[token]);
for diags in [&dangling, &with_file] {
assert!(
!has_any(diags, "backticked path")
&& !has_any(diags, "stale reference")
&& !has_any(diags, "convert to a markdown link"),
"excluded shape `{token}` draws no dark-matter diagnostic: {diags:?}"
);
}
}
}
#[test]
fn excluded_glob_bare_path_draws_no_diagnostic() {
let diags = diagnose("See docs/NN_*.md for details.\n");
assert!(
!has_any(&diags, "stale reference") && !has_any(&diags, "convert to a markdown link"),
"a bare glob path draws no dark-matter diagnostic: {diags:?}"
);
}
#[test]
fn plain_in_dir_dangling_still_warns() {
let diags = diagnose_at_path_with_files("docs/note.md", "See `gone.md`.\n", &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a plain in-dir dangling `.md` still warns: {diags:?}"
);
}
#[test]
fn root_file_still_resolves_via_root_base() {
let diags = diagnose_at_path_with_files("a/b/c.md", "See `/README.md`.\n", &["README.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "backticked path"),
1,
"a root-relative `/README.md` with the root file present still resolves: {diags:?}"
);
assert!(
!has_any(&diags, "stale reference"),
"the resolving root file draws no stale warning: {diags:?}"
);
}
#[test]
fn dotdot_escaping_root_is_not_a_resolution() {
let diags = diagnose_at_path_with_files(
"note.md",
"See `../outside.md` for details.\n",
&["outside.md"],
);
assert!(
!has_any(&diags, "backticked path"),
"an escaping `..` reference draws no make-it-a-link hint: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"an escaping `..` reference is a genuine stale reference: {diags:?}"
);
}
fn config_with_catenary_alias() -> Config {
let mut config = Config::default();
config.external.insert(
"Catenary".to_string(),
std::path::PathBuf::from("/ext/Catenary"),
);
config
}
#[test]
fn external_namespace_recognizer() {
assert_eq!(
block::external_namespace("{Catenary}/docs/x.md"),
Some(("Catenary", "docs/x.md")),
"a leading `{{ident}}/` is recognized, splitting alias from the remainder"
);
assert_eq!(
block::external_namespace("{my_repo-2}/x.md"),
Some(("my_repo-2", "x.md")),
"alphanumerics, `_` and `-` are valid identifier characters"
);
assert_eq!(
block::external_namespace("{Archive}/docs"),
Some(("Archive", "docs")),
"an extension-less directory remainder is recognized"
);
assert_eq!(
block::external_namespace("{Archive}/schema.txt"),
Some(("Archive", "schema.txt")),
"a non-`.md` file remainder is recognized"
);
for token in [
"{Catenary}", "{Catenary}/", "{}/x.md", "{a b}/x.md", "docs/{Catenary}.md", "Catenary/x.md", ] {
assert_eq!(
block::external_namespace(token),
None,
"`{token}` is not an external-namespace reference"
);
}
}
#[test]
fn external_undefined_alias_is_exempt() {
let diags = diagnose("See `{Catenary}/docs/configuration.md` for details.\n");
assert!(
!has_any(&diags, "stale reference")
&& !has_any(&diags, "backticked path")
&& !has_any(&diags, "convert to a markdown link"),
"an undefined `{{Name}}/…` alias draws no diagnostic (exempt floor): {diags:?}"
);
}
#[test]
fn external_alias_dir_absent_is_exempt() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See `{Catenary}/docs/configuration.md` for details.\n",
&config,
&[],
);
assert!(
!has_any(&diags, "stale reference"),
"a defined alias whose directory is absent is exempt: {diags:?}"
);
}
#[test]
fn external_alias_dir_present_file_present_is_valid() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See `{Catenary}/docs/configuration.md` for details.\n",
&config,
&["/ext/Catenary", "/ext/Catenary/docs/configuration.md"],
);
assert!(
!has_any(&diags, "stale reference")
&& !has_any(&diags, "backticked path")
&& !has_any(&diags, "convert to a markdown link"),
"a present external file is valid and draws no diagnostic: {diags:?}"
);
}
#[test]
fn external_alias_dir_present_file_missing_is_stale() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See `{Catenary}/docs/configuration.md` for details.\n",
&config,
&["/ext/Catenary"],
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference"),
1,
"a missing file under a present alias directory is stale: {diags:?}"
);
}
#[test]
fn external_reference_quoted_and_bare_forms() {
let config = config_with_catenary_alias();
for content in [
"See \"{Catenary}/docs/configuration.md\" for details.\n",
"See {Catenary}/docs/configuration.md for details.\n",
] {
let stale = diagnose_with_external(content, &config, &["/ext/Catenary"]);
assert_eq!(
count_matching(&stale, Severity::Warning, "stale reference"),
1,
"missing external file is stale exactly once on this surface: {stale:?}"
);
let exempt = diagnose(content);
assert!(
!has_any(&exempt, "stale reference"),
"undefined alias is exempt on this surface: {exempt:?}"
);
}
}
#[test]
fn external_reference_message_teaches_the_escape() {
let diags = diagnose("See `gone/missing.md` for details.\n");
assert!(
has_matching(&diags, Severity::Warning, "{repo}/") && has_any(&diags, ".lattice.toml"),
"the stale message teaches the `{{repo}}/…` external escape: {diags:?}"
);
}
#[test]
fn external_directory_and_non_md_references_are_checked() {
let config = config_with_catenary_alias();
for reference in ["{Catenary}/docs", "{Catenary}/schema.txt"] {
for content in [
format!("See {reference} for details.\n"), format!("See `{reference}` for details.\n"), format!("See \"{reference}\" for details.\n"), ] {
let stale = diagnose_with_external(&content, &config, &["/ext/Catenary"]);
assert_eq!(
count_matching(&stale, Severity::Warning, "stale reference"),
1,
"a missing external `{reference}` is stale exactly once: {stale:?}"
);
let exempt_absent = diagnose_with_external(&content, &config, &[]);
assert!(
!has_any(&exempt_absent, "stale reference"),
"an absent alias directory is exempt for `{reference}`: {exempt_absent:?}"
);
let exempt_undef = diagnose(&content);
assert!(
!has_any(&exempt_undef, "stale reference"),
"an undefined alias is exempt for `{reference}`: {exempt_undef:?}"
);
}
}
}
#[test]
fn external_directory_reference_present_is_valid() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See `{Catenary}/docs` for details.\n",
&config,
&["/ext/Catenary", "/ext/Catenary/docs"],
);
assert!(
!has_any(&diags, "stale reference") && !has_any(&diags, "backticked path"),
"a present external directory is valid and draws no diagnostic: {diags:?}"
);
}
#[test]
fn external_stale_message_names_alias_not_intra_repo_escape() {
let config = config_with_catenary_alias();
let diags = diagnose_with_external(
"See `{Catenary}/docs` for details.\n",
&config,
&["/ext/Catenary"],
);
assert!(
has_matching(&diags, Severity::Warning, "external alias `Catenary`"),
"the external stale message names the alias: {diags:?}"
);
assert!(
!has_any(&diags, "under this root") && !has_any(&diags, "markdown file"),
"it drops the intra-repo 'markdown file under this root' framing: {diags:?}"
);
assert!(
!has_any(&diags, "{repo}/"),
"it does not teach the `{{repo}}/…` escape for a reference already using it: {diags:?}"
);
}
#[test]
fn external_namespace_ellipsis_placeholder_is_exempt() {
let config = config_with_catenary_alias();
for placeholder in ["{Catenary}/…", "{Catenary}/..."] {
for content in [
format!("Write it as {placeholder} for a cross-repo ref.\n"),
format!("Write it as `{placeholder}` for a cross-repo ref.\n"),
format!("Write it as \"{placeholder}\" for a cross-repo ref.\n"),
] {
let diags = diagnose_with_external(&content, &config, &["/ext/Catenary"]);
assert!(
!has_any(&diags, "stale reference") && !has_any(&diags, "external alias"),
"the `{placeholder}` placeholder is exempt, not a stale reference: {diags:?}"
);
}
}
}
#[test]
fn external_reference_is_never_a_graph_edge() {
let tree = block::parse_tree(
"See `{Catenary}/docs/configuration.md` and {Catenary}/x.md.\n",
None,
);
let links = tree.links(std::path::Path::new("test.md"));
assert!(
links.is_empty(),
"an external `{{Name}}/…` citation forms no graph edge: {links:?}"
);
}
#[test]
fn backticked_path_in_table_cell_emits_hint() {
let content = "| # | Tracker |\n|---|---------|\n| 1 | `tickets/foo/README.md` |\n";
let diags = diagnose_with_files(content, &["tickets/foo/README.md"]);
let hits: Vec<&Diagnostic> = diags
.iter()
.filter(|d| d.severity == Severity::Hint && d.message.contains("backticked path"))
.collect();
assert_eq!(
hits.len(),
1,
"exactly one backticked-path hint for the cell: {diags:?}"
);
assert_eq!(
hits[0].line, 3,
"hint is anchored at the table cell's row (line 3): {diags:?}"
);
}
#[test]
fn backticked_path_in_table_cell_no_file() {
let content = "| # | Tracker |\n|---|---------|\n| 1 | `tickets/foo/README.md` |\n";
let diags = diagnose(content);
assert!(
!has_any(&diags, "backticked path"),
"no hint for a non-existent cell path: {diags:?}"
);
}
#[test]
fn bare_url_in_table_cell_emits_warning() {
let content = "| Site |\n|------|\n| https://example.com/page |\n";
let diags = diagnose(content);
assert_eq!(
count_matching(&diags, Severity::Warning, "bare URL"),
1,
"one bare-URL warning for the cell: {diags:?}"
);
}
#[test]
fn quoted_path_in_table_cell_emits_hint() {
let content = "| Ref |\n|-----|\n| \"other.md\" |\n";
let diags = diagnose_with_files(content, &["other.md"]);
assert_eq!(
count_matching(&diags, Severity::Hint, "quoted path"),
1,
"one quoted-path hint for the cell: {diags:?}"
);
}
#[test]
fn bare_path_in_table_cell_emits_diagnostic() {
let content = "| Ref |\n|-----|\n| docs/page.md |\n";
let diags = diagnose_with_files(content, &["docs/page.md"]);
assert_eq!(
count_matching(&diags, Severity::Warning, "convert to a markdown link"),
1,
"one bare-path diagnostic for the cell: {diags:?}"
);
}
#[test]
fn self_closing_div() {
let diags = diagnose("<div/>\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "self-closing non-void"),
1,
"one warning for self-closing div: {diags:?}"
);
}
#[test]
fn self_closing_void_ok() {
let diags = diagnose("<br/>\n");
assert!(
!has_any(&diags, "self-closing non-void"),
"no warning for self-closing void: {diags:?}"
);
}
#[test]
fn unknown_element() {
let diags = diagnose("<foo>\n</foo>\n");
assert_eq!(
count_matching(&diags, Severity::Info, "unknown HTML element"),
1,
"one info for unknown element: {diags:?}"
);
}
#[test]
fn duplicate_id_across_block_and_mid_paragraph_inline() {
let diags = diagnose(
"<div id=\"shared\"></div>\n\n\
Paragraph with an <span id=\"shared\"></span> inline target.\n",
);
assert_eq!(
count_matching(&diags, Severity::Error, "duplicate `id` attribute `shared`"),
1,
"one error for the inline id duplicating the block id: {diags:?}"
);
}
#[test]
fn distinct_mid_paragraph_inline_id_no_duplicate() {
let diags = diagnose(
"<div id=\"block\"></div>\n\n\
Paragraph with an <span id=\"inline\"></span> inline target.\n",
);
assert!(
!has_any(&diags, "duplicate `id`"),
"distinct ids do not collide: {diags:?}"
);
}
#[test]
fn code_block_language_disabled() {
let fm = yaml::parse_frontmatter_block("```\ncode\n```\n");
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree("```\ncode\n```\n", fm_span);
let mut config = Config::default();
config.policy.code_block_language = CodeBlockLanguagePolicy::Disabled;
let rel_path = std::path::Path::new("test.md");
let diags = collect(
&tree,
rel_path,
&config,
&|_| false,
&|_| false,
&Exceptions::default(),
);
assert!(
!has_any(&diags, "language tag"),
"no diagnostic when disabled: {diags:?}"
);
}
#[test]
fn code_block_language_deny_is_error() {
let fm = yaml::parse_frontmatter_block("```\ncode\n```\n");
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree("```\ncode\n```\n", fm_span);
let mut config = Config::default();
config.policy.code_block_language = CodeBlockLanguagePolicy::Deny;
let rel_path = std::path::Path::new("test.md");
let diags = collect(
&tree,
rel_path,
&config,
&|_| false,
&|_| false,
&Exceptions::default(),
);
assert_eq!(
count_matching(&diags, Severity::Error, "without a language tag"),
1,
"one error when deny: {diags:?}"
);
}
fn diagnose_with_policy(
content: &str,
existing: &[&str],
policy: BarePathPolicy,
) -> Vec<Diagnostic> {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let mut config = Config::default();
config.policy.bare_paths = policy;
let rel_path = std::path::Path::new("test.md");
let existing_set: HashSet<&str> = existing.iter().copied().collect();
let exceptions = exceptions_of(content);
collect(
&tree,
rel_path,
&config,
&|p| existing_set.contains(p.to_str().unwrap_or("")),
&|_| false,
&exceptions,
)
}
const BARE_PATH_SAMPLE: &str =
"Visit https://example.com and see \"other.md\" or `other.md` in docs/page.md here.\n";
const BARE_PATH_NEEDLES: [&str; 4] = [
"convert to a markdown link",
"bare URL",
"quoted path",
"backticked path",
];
#[test]
fn bare_paths_disabled_silences_both_emitters() {
let diags = diagnose_with_policy(
BARE_PATH_SAMPLE,
&["other.md", "docs/page.md"],
BarePathPolicy::Disabled,
);
for needle in BARE_PATH_NEEDLES {
assert!(
!has_any(&diags, needle),
"disabled should silence `{needle}`: {diags:?}"
);
}
}
#[test]
fn bare_paths_deny_escalates_both_emitters() {
let diags = diagnose_with_policy(
BARE_PATH_SAMPLE,
&["other.md", "docs/page.md"],
BarePathPolicy::Deny,
);
for needle in BARE_PATH_NEEDLES {
assert!(
has_matching(&diags, Severity::Error, needle),
"deny should escalate `{needle}` to error: {diags:?}"
);
}
}
#[test]
fn html_in_blockquote_closed_on_blank_line() {
let diags = diagnose("> <div>\n>\n> text\n\nparagraph\n");
assert_eq!(
count_matching(&diags, Severity::Error, "unclosed"),
1,
"one unclosed div error, no cascading: {diags:?}"
);
}
#[test]
fn malformed_link_destination() {
let diags = diagnose("[text](\n");
assert_eq!(
count_matching(&diags, Severity::Error, "malformed link"),
1,
"one error for malformed link: {diags:?}"
);
}
#[test]
fn unused_ref_def_is_warning() {
let diags = diagnose("[label]: https://example.com\n\nSome text.\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "unused reference definition"),
1,
"unused ref def should be warning: {diags:?}"
);
assert!(
!has_matching(&diags, Severity::Error, "unused reference definition"),
"unused ref def should not be error: {diags:?}"
);
}
#[test]
fn duplicate_ref_def_is_warning() {
let diags = diagnose("[label]: https://a.com\n[label]: https://b.com\n\n[text][label]\n");
assert_eq!(
count_matching(&diags, Severity::Warning, "duplicate reference definition"),
1,
"duplicate ref def should be warning: {diags:?}"
);
}
#[test]
fn markdown_in_opaque_html_warns() {
let diags = diagnose("<center>\n# Heading\n</center>\n");
assert_eq!(
count_matching(
&diags,
Severity::Warning,
"markdown syntax inside HTML block"
),
1,
"one warning for markdown in opaque HTML: {diags:?}"
);
}
#[test]
fn exception_suppresses_unresolved_stale_reference() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"hypothetical path in the worked example\"\n\
---\n\
See `gone.md` for details.\n";
let diags = diagnose_with_files(content, &[]);
assert!(
!has_any(&diags, "stale reference"),
"the exception suppresses the stale-reference diagnostic: {diags:?}"
);
assert!(
!has_any(&diags, "unused exception"),
"a matched exception is not flagged as unused: {diags:?}"
);
}
#[test]
fn exception_with_no_live_diagnostic_is_unused_and_echoes_reason() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"hypothetical path in the worked example\"\n\
---\n\
Nothing references it now.\n";
let diags = diagnose_with_files(content, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "unused exception: `gone.md`"),
1,
"an exception matching no live diagnostic is flagged as unused: {diags:?}"
);
assert!(
has_any(&diags, "hypothetical path in the worked example"),
"the unused-exception message echoes the stored reason: {diags:?}"
);
}
#[test]
fn exception_with_empty_reason_is_a_diagnostic() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"\"\n\
---\n\
See `gone.md` here.\n";
let diags = diagnose_with_files(content, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "has no reason"),
1,
"an empty-reason exception is a diagnostic: {diags:?}"
);
assert!(
!has_any(&diags, "unused exception"),
"a matched empty-reason entry is not also flagged unused: {diags:?}"
);
}
#[test]
fn external_alias_keyed_exception_suppresses_present_missing_stale() {
let config = config_with_catenary_alias();
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"{Catenary}/old/layout.md\": \"pre-refactor path, kept for the changelog note\"\n\
---\n\
See `{Catenary}/old/layout.md` for the old shape.\n";
let diags = diagnose_with_external(content, &config, &["/ext/Catenary"]);
assert!(
!has_any(&diags, "stale reference"),
"a `{{Name}}/…`-keyed exception suppresses the present-missing stale: {diags:?}"
);
assert!(
!has_any(&diags, "unused exception"),
"the matched alias-keyed exception is not flagged unused: {diags:?}"
);
}
#[test]
fn exception_scope_is_per_reference() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"excepted.md\": \"deliberately not a live reference\"\n\
---\n\
See `excepted.md` and also `other.md`.\n";
let diags = diagnose_with_files(content, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `other.md`"),
1,
"the unexcepted reference still fires: {diags:?}"
);
assert!(
!has_any(&diags, "stale reference: `excepted.md`"),
"the excepted reference is suppressed: {diags:?}"
);
}
#[test]
fn exception_is_never_a_graph_edge_or_backlink_obligation() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"deliberately dead\"\n\
---\n\
Body text.\n";
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let links = tree.links(std::path::Path::new("test.md"));
assert!(
links.is_empty(),
"an exception forms no graph edge: {links:?}"
);
}
#[test]
fn rename_flags_old_key_unused_while_new_name_fires() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"old-name.md\": \"the design doc, since renamed\"\n\
---\n\
See `new-name.md` for the design.\n";
let diags = diagnose_with_files(content, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "unused exception: `old-name.md`"),
1,
"the renamed-away old key is flagged unused: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `new-name.md`"),
1,
"the new name fires a fresh stale reference: {diags:?}"
);
}
#[test]
fn bare_paths_exception_suppresses_resolve_hint() {
let content = "---\n\
exceptions:\n \
bare_paths:\n \
\"README.md\": \"naming the file, deliberately not a link\"\n\
---\n\
See `README.md` for the overview.\n";
let diags = diagnose_with_files(content, &["README.md"]);
assert!(
!has_any(&diags, "backticked path"),
"the bare_paths exception suppresses the resolve hint: {diags:?}"
);
assert!(
!has_any(&diags, "unused exception"),
"the matched bare_paths exception is not flagged unused: {diags:?}"
);
}
#[test]
fn exception_round_trips_both_namespaces_and_alias_keys() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"tickets/acquire/DESIGN.md\": \"hypothetical path in the worked example\"\n \
\"{Catenary}/old/layout.md\": \"pre-refactor path\"\n \
bare_paths:\n \
\"README\": \"naming the file, deliberately not a link\"\n\
---\n\
Body.\n";
let exceptions = exceptions_of(content);
assert_eq!(
exceptions.stale_references.len(),
2,
"two stale_references exceptions parsed: {exceptions:?}"
);
assert_eq!(
exceptions.bare_paths.len(),
1,
"one bare_paths exception parsed: {exceptions:?}"
);
assert_eq!(
exceptions.stale_references[0].reference, "tickets/acquire/DESIGN.md",
"the first stale key is the literal reference: {exceptions:?}"
);
assert_eq!(
exceptions.stale_references[1].reference, "{Catenary}/old/layout.md",
"the `{{Name}}/…` key is retained verbatim: {exceptions:?}"
);
assert_eq!(
exceptions.bare_paths[0].reference, "README",
"the bare_paths key is the literal reference: {exceptions:?}"
);
assert_eq!(
exceptions.stale_references[0].reason, "hypothetical path in the worked example",
"the reason is the map value: {exceptions:?}"
);
}
#[test]
fn exception_for_disabled_lint_is_not_flagged_unused() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"deliberately dead\"\n\
---\n\
Nothing references it.\n";
let diags = diagnose_with_stale_policy(content, &[], StaleReferencePolicy::Disabled);
assert!(
!has_any(&diags, "unused exception"),
"a disabled lint's exceptions are not flagged unused: {diags:?}"
);
}
#[test]
fn stale_reference_message_points_at_config_help() {
let diags = diagnose("See `gone/missing.md` for details.\n");
assert!(
has_matching(&diags, Severity::Warning, "lattice help config"),
"the stale-reference message names `lattice help config`: {diags:?}"
);
}
#[test]
fn make_it_a_link_message_names_both_escapes_and_config_help() {
let diags = diagnose_with_files("See `other.md` for details.\n", &["other.md"]);
assert!(
has_matching(&diags, Severity::Hint, "drop the extension"),
"the hint still offers drop-the-extension: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "except it with a reason"),
"the hint names the frontmatter exception escape with its required reason (FU2): {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "lattice help config"),
"the hint points at `lattice help config`: {diags:?}"
);
}
#[test]
fn stale_reference_message_frames_the_move_test() {
let diags = diagnose("See `gone.md` here.\n");
assert!(
has_matching(&diags, Severity::Warning, "stale reference: `gone.md`"),
"the stale-reference message still fires: {diags:?}"
);
assert!(
has_matching(
&diags,
Severity::Warning,
"would moving the target update this"
),
"the stale-reference message frames the choice as the move test: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Warning, "lattice help config"),
"the stale-reference message keeps the config pointer: {diags:?}"
);
}
#[test]
fn make_it_a_link_message_frames_the_move_test() {
let diags = diagnose_with_files("See `other.md` here.\n", &["other.md"]);
assert!(
has_matching(&diags, Severity::Hint, "backticked path `other.md`"),
"the make-it-a-link hint still fires: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "would moving it update this"),
"the make-it-a-link hint frames the choice as the move test: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "make it a link"),
"the make-it-a-link hint keeps the link resolution: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "lattice help config"),
"the make-it-a-link hint keeps the config pointer: {diags:?}"
);
}
#[test]
fn bare_path_make_it_a_link_message_points_at_config_help() {
let diags = diagnose_with_files("See docs/other.md for details.\n", &["docs/other.md"]);
assert!(
has_matching(&diags, Severity::Warning, "convert to a markdown link"),
"the bare-path nudge still fires: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Warning, "lattice help config"),
"the bare-path nudge points at `lattice help config`: {diags:?}"
);
}
#[test]
fn quoted_path_message_points_at_config_help() {
let diags = diagnose_with_files("See \"docs/other.md\" for details.\n", &["docs/other.md"]);
assert!(
has_matching(&diags, Severity::Hint, "quoted path"),
"the quoted-path hint still fires: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Hint, "lattice help config"),
"the quoted-path hint points at `lattice help config`: {diags:?}"
);
}
#[test]
fn unused_exception_message_points_at_config_help() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"hypothetical path in the worked example\"\n\
---\n\
Nothing references it now.\n";
let diags = diagnose_with_files(content, &[]);
assert!(
has_matching(&diags, Severity::Warning, "unused exception: `gone.md`",),
"the unused-exception message still fires: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Warning, "lattice help config"),
"the unused-exception message points at `lattice help config`: {diags:?}"
);
}
#[test]
fn empty_reason_message_points_at_config_help() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"\"\n\
---\n\
See `gone.md` here.\n";
let diags = diagnose_with_files(content, &[]);
assert!(
has_matching(&diags, Severity::Warning, "has no reason"),
"the empty-reason message still fires: {diags:?}"
);
assert!(
has_matching(&diags, Severity::Warning, "lattice help config"),
"the empty-reason message points at `lattice help config`: {diags:?}"
);
}
fn diagnose_full(
content: &str,
config: &Config,
existing: &[&str],
) -> (Vec<Diagnostic>, FileSuppressions) {
let fm = yaml::parse_frontmatter_block(content);
let fm_span = fm.as_ref().map(|b| b.span);
let tree = block::parse_tree(content, fm_span);
let rel_path = std::path::Path::new("test.md");
let existing_set: HashSet<&str> = existing.iter().copied().collect();
let exceptions = exceptions_of(content);
collect_with_suppressions(
&tree,
rel_path,
config,
&|p| existing_set.contains(p.to_str().unwrap_or("")),
&|_| false,
&exceptions,
)
}
fn three_stale_with_count(count: &str) -> String {
format!(
"---\n\
exceptions:\n \
stale_references:\n \
\"{count}\": \"migration table — every path is a record, not a live reference\"\n\
---\n\
See `a.md`, `b.md`, and `c.md`.\n"
)
}
#[test]
fn count_key_suppresses_iff_residual_equals_n() {
let config = Config::default();
let (diags, sup) = diagnose_full(&three_stale_with_count("3"), &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"a count-key of N == M suppresses the whole residual: {diags:?}"
);
assert!(
!has_any(&diags, "expected"),
"no drift warning when the count matches: {diags:?}"
);
let count_key = &sup.count_keys;
assert_eq!(
count_key.len(),
1,
"the matched count-key produces one ledger row: {sup:?}"
);
assert_eq!(
count_key[0].counts.warnings, 3,
"the ledger tallies the three suppressed warnings: {sup:?}"
);
assert_eq!(
count_key[0].raw, "3",
"the row carries the raw key: {sup:?}"
);
}
#[test]
fn count_key_one_too_many_resurfaces_and_flags() {
let config = Config::default();
let (diags, sup) = diagnose_full(&three_stale_with_count("2"), &config, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `"),
3,
"every residual diagnostic resurfaces on drift: {diags:?}"
);
assert!(
has_matching(
&diags,
Severity::Warning,
"expected 2 stale references here, found 3"
),
"the drift warning names N and M: {diags:?}"
);
assert!(
sup.count_keys.is_empty(),
"a drifted count-key suppresses nothing, so no ledger row: {sup:?}"
);
}
#[test]
fn count_key_one_too_few_resurfaces_and_flags() {
let config = Config::default();
let (diags, sup) = diagnose_full(&three_stale_with_count("4"), &config, &[]);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `"),
3,
"every residual diagnostic resurfaces on drift: {diags:?}"
);
assert!(
has_matching(
&diags,
Severity::Warning,
"expected 4 stale references here, found 3"
),
"the drift warning names N and M: {diags:?}"
);
assert!(
sup.count_keys.is_empty(),
"a drifted count-key suppresses nothing: {sup:?}"
);
}
#[test]
fn count_key_and_literal_compose() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"a.md\": \"the worked example path\"\n \
\"2\": \"the rest of the migration table\"\n\
---\n\
See `a.md`, `b.md`, and `c.md`.\n";
let config = Config::default();
let (diags, sup) = diagnose_full(content, &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"the literal carves one out and the count covers the rest: {diags:?}"
);
assert!(
!has_any(&diags, "expected"),
"no drift: the residual after the literal is exactly N: {diags:?}"
);
let ex = sup
.exceptions
.as_ref()
.expect("the literal exception suppressed one");
assert_eq!(
ex.counts.warnings, 1,
"the literal row tallies its one suppression: {sup:?}"
);
assert_eq!(ex.matched_entries, 1, "one literal entry matched: {sup:?}");
assert_eq!(
sup.count_keys.first().map(|c| c.counts.warnings),
Some(2),
"the count-key row tallies the residual of two: {sup:?}"
);
}
#[test]
fn count_key_with_empty_reason_is_diagnosed() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"3\": \"\"\n\
---\n\
See `a.md`, `b.md`, and `c.md`.\n";
let config = Config::default();
let (diags, sup) = diagnose_full(content, &config, &[]);
assert!(
has_matching(&diags, Severity::Warning, "count-key `3`")
&& has_matching(&diags, Severity::Warning, "has no reason"),
"an empty-reason count-key is diagnosed at the key: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `"),
3,
"the residual resurfaces under an empty-reason count-key: {diags:?}"
);
assert!(
sup.count_keys.is_empty(),
"an empty-reason count-key suppresses nothing: {sup:?}"
);
}
#[test]
fn count_key_of_zero_is_diagnosed() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"0\": \"a reason\"\n\
---\n\
See `a.md`.\n";
let config = Config::default();
let (diags, _sup) = diagnose_full(content, &config, &[]);
assert!(
has_matching(&diags, Severity::Warning, "must be at least 1"),
"a zero count-key is diagnosed: {diags:?}"
);
assert_eq!(
count_matching(&diags, Severity::Warning, "stale reference: `"),
1,
"the residual resurfaces under a zero count-key: {diags:?}"
);
}
#[test]
fn count_key_under_disabled_lint_is_inert() {
let mut config = Config::default();
config.policy.stale_references = StaleReferencePolicy::Disabled;
let (diags, sup) = diagnose_full(&three_stale_with_count("99"), &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"a disabled lint emits no stale references: {diags:?}"
);
assert!(
!has_any(&diags, "expected"),
"a disabled lint's count-key raises no drift flag: {diags:?}"
);
assert!(
sup.is_empty(),
"a disabled-lint count-key suppresses nothing: {sup:?}"
);
}
#[test]
fn count_key_shape_discrimination() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"31.md\": \"a literal path-shaped key\"\n \
\"a/31.md\": \"another literal path-shaped key\"\n \
\"1\": \"the residual count sentinel\"\n\
---\n\
See `31.md`, `a/31.md`, and `loose.md`.\n";
let config = Config::default();
let (diags, sup) = diagnose_full(content, &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"the two literals carve out, the sentinel claims the rest: {diags:?}"
);
let ex = sup
.exceptions
.as_ref()
.expect("the two path-shaped literals suppressed");
assert_eq!(
ex.matched_entries, 2,
"`31.md` and `a/31.md` are literal entries, both matched: {sup:?}"
);
assert_eq!(
sup.count_keys.first().map(|c| c.counts.warnings),
Some(1),
"the `1` sentinel claims the single residual: {sup:?}"
);
}
#[test]
fn classify_028_lint_maps_each_message_family() {
assert_eq!(
classify_028_lint("stale reference: `gone.md` — no such markdown file"),
Some(ExceptionLint::StaleReferences),
"the stale-reference message maps to StaleReferences"
);
for bare in [
"bare path `docs/x.md`: convert to a markdown link",
"bare URL `https://x` : wrap in angle brackets",
"quoted path `\"x.md\"`: use backticks",
"backticked path `x.md` refers to an existing file",
] {
assert_eq!(
classify_028_lint(bare),
Some(ExceptionLint::BarePaths),
"a bare_paths-family message maps to BarePaths: {bare}"
);
}
assert_eq!(
classify_028_lint("empty heading"),
None,
"a non-028 message maps to neither lint"
);
assert_eq!(
classify_028_lint("duplicate heading slug `x`"),
None,
"another non-028 message maps to neither lint"
);
}
fn config_with_artifacts(names: &[&str]) -> Config {
Config {
artifacts: names.iter().map(|s| (*s).to_string()).collect(),
..Config::default()
}
}
#[test]
fn artifact_name_resolving_draws_no_make_it_a_link_hint() {
let config = config_with_artifacts(&["AGENTS.md"]);
let (diags, sup) =
diagnose_full("See `AGENTS.md` for the hooks.\n", &config, &["AGENTS.md"]);
assert!(
!has_any(&diags, "make it a link"),
"a glossary artifact draws no make-it-a-link hint even when it resolves: {diags:?}"
);
assert!(
!has_any(&diags, "AGENTS.md"),
"no diagnostic mentions the artifact at all: {diags:?}"
);
assert_eq!(
sup.artifacts.get("AGENTS.md").map(|c| c.hints),
Some(1),
"the swallowed hint is recorded in the ledger tally: {sup:?}"
);
}
#[test]
fn artifact_name_dangling_draws_no_stale_reference() {
let config = config_with_artifacts(&["GEMINI.md"]);
let (diags, sup) = diagnose_full("Put hooks in `GEMINI.md`.\n", &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"a glossary artifact draws no stale-reference diagnostic when it dangles: {diags:?}"
);
assert_eq!(
sup.artifacts.get("GEMINI.md").map(|c| c.warnings),
Some(1),
"the swallowed stale-reference warning is recorded in the ledger tally: {sup:?}"
);
}
#[test]
fn artifact_exact_match_only_path_qualified_still_flags() {
let config = config_with_artifacts(&["AGENTS.md"]);
let (diags, sup) = diagnose_full("See `dir/AGENTS.md`.\n", &config, &[]);
assert!(
has_matching(
&diags,
Severity::Warning,
"stale reference: `dir/AGENTS.md`"
),
"a path-qualified reference is not the bare artifact and still flags: {diags:?}"
);
assert!(
sup.artifacts.is_empty(),
"the path-qualified reference produced no artifact suppression: {sup:?}"
);
}
#[test]
fn artifact_quoted_and_backticked_both_filtered() {
let config = config_with_artifacts(&["CLAUDE.md"]);
let (diags, sup) =
diagnose_full("Edit \"CLAUDE.md\" and also `CLAUDE.md`.\n", &config, &[]);
assert!(
!has_any(&diags, "CLAUDE.md"),
"neither the quoted nor the backticked artifact mention is flagged: {diags:?}"
);
assert_eq!(
sup.artifacts.get("CLAUDE.md").map(|c| c.warnings),
Some(2),
"both dark-matter mentions are tallied: {sup:?}"
);
}
#[test]
fn artifact_filtered_before_count_key_residual() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"2\": \"the two genuine dangling references\"\n\
---\n\
See `AGENTS.md`, `a.md`, and `b.md`.\n";
let config = config_with_artifacts(&["AGENTS.md"]);
let (diags, sup) = diagnose_full(content, &config, &[]);
assert!(
!has_any(&diags, "stale reference"),
"the count-key of 2 covers the two genuine refs; the artifact was filtered first: {diags:?}"
);
assert!(
!has_any(&diags, "expected"),
"no drift — the artifact never entered the residual, so the residual is exactly 2: {diags:?}"
);
assert_eq!(
sup.count_keys.first().map(|c| c.counts.warnings),
Some(2),
"the count-key residual is the two genuine refs, not three: {sup:?}"
);
assert_eq!(
sup.artifacts.get("AGENTS.md").map(|c| c.warnings),
Some(1),
"the artifact is tallied as its own source, not folded into the count-key: {sup:?}"
);
}
#[test]
fn artifact_is_not_exceptable() {
let content = "---\n\
exceptions:\n \
stale_references:\n \
\"SKILL.md\": \"trying (wrongly) to except the artifact here\"\n\
---\n\
See `SKILL.md`.\n";
let config = config_with_artifacts(&["SKILL.md"]);
let (diags, sup) = diagnose_full(content, &config, &[]);
assert!(
has_matching(&diags, Severity::Warning, "unused exception: `SKILL.md`"),
"the exception keyed on the artifact matches nothing live (the glossary filtered it first): {diags:?}"
);
assert!(
sup.exceptions.is_none(),
"the artifact was not suppressed by the exception: {sup:?}"
);
assert_eq!(
sup.artifacts.get("SKILL.md").map(|c| c.warnings),
Some(1),
"the artifact suppression is recorded under the artifact source: {sup:?}"
);
}
#[test]
fn no_glossary_keeps_current_behaviour() {
let config = Config::default();
let (diags, sup) = diagnose_full("See `AGENTS.md`.\n", &config, &[]);
assert!(
has_matching(&diags, Severity::Warning, "stale reference: `AGENTS.md`"),
"an empty glossary leaves the name to flag normally: {diags:?}"
);
assert!(
sup.artifacts.is_empty(),
"an empty glossary records no artifact suppression: {sup:?}"
);
}
}