use std::collections::HashSet;
use std::path::PathBuf;
use crate::ast::environment_name;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::project::labels::EquationRangeFacts;
use crate::syntax::SyntaxKind;
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "A label that no `\\ref`-family command ever targets:",
source: "\\section{Intro}\\label{sec:intro}\n",
}];
pub struct UnreferencedLabel;
impl Rule for UnreferencedLabel {
fn id(&self) -> &'static str {
"unreferenced-label"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a label definition unused by a `\\ref`-family command anywhere in the \
document. A `\\eqref{A}--\\eqref{D}` range also uses labels between A and D \
when they occur in consecutive, singly labeled `equation` environments \
or numbered `align` and `gather` rows, including through literal \
included files with an unambiguous source order. Manual tags, \
suppressed numbers, and counter changes stop inference. Referencing a \
`subequations` group label also uses the labels in its enclosed math \
environments. The mirror of `undefined-ref`, \
and sound only when the label \
namespace is complete, so it stays silent unless the project view is \
**closed** (every include resolves to an analyzed file) and **rooted**. \
Inert on stdin or wherever no cross-file label resolution is available. \
Report-only: removing the dead label or adding a reference are both \
valid, so there is no autofix."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(resolution) = ctx.resolution else {
return;
};
if !resolution.is_closed(ctx.path) || !resolution.is_root_component(ctx.path) {
return;
}
let ranged = (!resolution.has_range_facts(ctx.path)).then(|| {
EquationRangeFacts::collect(ctx.path, ctx.root, ctx.model).local_inferred(resolution)
});
let grouped = referenced_subequation_labels(ctx);
sink.extend(
ctx.model
.labels()
.iter()
.filter(|label| {
!resolution.is_referenced(ctx.path, &label.name)
&& !resolution
.is_range_referenced(ctx.path, usize::from(label.range.start()))
&& !ranged.as_ref().is_some_and(|offsets| {
offsets.contains(&usize::from(label.range.start()))
})
&& !grouped.contains(&usize::from(label.range.start()))
})
.map(|label| Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(label.range.start()),
end: usize::from(label.range.end()),
message: format!("label `{}` is never referenced", label.name),
fix: None,
related: Vec::new(),
}),
);
}
}
fn referenced_subequation_labels(ctx: &RuleContext<'_>) -> HashSet<usize> {
let Some(resolution) = ctx.resolution else {
return HashSet::new();
};
let mut grouped = HashSet::new();
for group in ctx.root.descendants().filter(|node| {
node.kind() == SyntaxKind::ENVIRONMENT
&& node
.children()
.find(|child| child.kind() == SyntaxKind::BEGIN)
.and_then(|begin| environment_name(&begin))
.as_deref()
== Some("subequations")
}) {
if !group.children().any(|child| {
child.kind() == SyntaxKind::END
&& environment_name(&child).as_deref() == Some("subequations")
}) {
continue;
}
let nested: Vec<_> = group
.descendants()
.filter(|node| node.kind() == SyntaxKind::ENVIRONMENT && *node != group)
.collect();
let parent_referenced = ctx.model.labels().iter().any(|label| {
group.text_range().contains_range(label.range)
&& !nested
.iter()
.any(|environment| environment.text_range().contains_range(label.range))
&& resolution.is_referenced(ctx.path, &label.name)
&& resolution.definers(ctx.path, &label.name) == [ctx.path]
&& ctx
.model
.labels()
.iter()
.filter(|other| other.name == label.name)
.count()
== 1
});
if !parent_referenced {
continue;
}
for environment in nested {
let Some(math) = environment
.children()
.find(|child| child.kind() == SyntaxKind::MATH)
else {
continue;
};
for label in ctx
.model
.labels()
.iter()
.filter(|label| math.text_range().contains_range(label.range))
{
grouped.insert(usize::from(label.range.start()));
}
}
}
grouped
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::project::ResolvedLabels;
use crate::project::graph::{FileFacts, IncludeGraph};
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
use smol_str::SmolStr;
const DOC: &str = "doc.tex";
fn resolution(labels: &[&str], refs: &[&str], rooted: bool) -> ResolvedLabels {
let graph = IncludeGraph::build(
&[FileFacts {
path: PathBuf::from(DOC),
include_edges: Vec::new(),
}],
None,
);
ResolvedLabels::build(
&[(
PathBuf::from(DOC),
labels.iter().map(SmolStr::new).collect(),
refs.iter().map(SmolStr::new).collect(),
rooted,
)],
&graph,
)
}
fn findings(src: &str, resolution: Option<&ResolvedLabels>) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(
std::path::Path::new(DOC),
&root,
&model,
resolution,
None,
None,
);
let mut out = Vec::new();
UnreferencedLabel.check_file(&ctx, &mut out);
out
}
#[test]
fn flags_label_with_no_reference() {
let r = resolution(&["sec:intro"], &[], true);
let out = findings("\\label{sec:intro}\n", Some(&r));
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "unreferenced-label");
assert!(out[0].message.contains("sec:intro"));
assert!(out[0].fix.is_none());
}
#[test]
fn referenced_label_is_fine() {
let r = resolution(&["here"], &["here"], true);
assert!(findings("\\label{here}\\ref{here}\n", Some(&r)).is_empty());
}
#[test]
fn inert_without_resolution() {
assert!(findings("\\label{orphan}\n", None).is_empty());
}
#[test]
fn rootless_namespace_does_not_fire() {
let r = resolution(&["orphan"], &[], false);
assert!(findings("\\label{orphan}\n", Some(&r)).is_empty());
}
#[test]
fn open_namespace_does_not_fire() {
let graph = IncludeGraph::build(
&[FileFacts {
path: PathBuf::from(DOC),
include_edges: vec![crate::project::include::IncludeEdgeKey {
kind: crate::project::IncludeKind::Input,
target: crate::project::IncludeTarget::Dynamic,
}],
}],
None,
);
let r = ResolvedLabels::build(
&[(
PathBuf::from(DOC),
vec![SmolStr::new("orphan")],
Vec::new(),
true,
)],
&graph,
);
assert!(!r.is_closed(std::path::Path::new(DOC)));
assert!(findings("\\input{x}\\label{orphan}\n", Some(&r)).is_empty());
}
#[test]
fn flags_only_the_unreferenced_label() {
let r = resolution(&["used", "dead"], &["used"], true);
let out = findings("\\label{used}\\ref{used}\\label{dead}\n", Some(&r));
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("dead"));
}
}