use std::path::PathBuf;
use crate::ast::{AstNode, Environment, command_name};
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::semantic::signature::{self, OutlineKind};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "A `\\label` above its `\\caption` picks up the section counter, not the figure number:",
source: "\\begin{figure}\n \\includegraphics{plot}\n \\label{fig:plot}\n \\caption{A plot.}\n\\end{figure}\n",
}];
const CAPTION_COMMANDS: &[&str] = &[
"caption",
"captionof",
"captionlistentry",
"phantomcaption",
"subcaption",
"subcaptionbox",
];
const COUNTER_STEPPERS: &[&str] = &["refstepcounter", "stepcounter"];
pub struct LabelBeforeCaption;
impl Rule for LabelBeforeCaption {
fn id(&self) -> &'static str {
"label-before-caption"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn emits_fix(&self) -> bool {
true
}
fn description(&self) -> &'static str {
"Flag a `\\label` placed before the `\\caption` inside a float \
(`figure`, `table`, and their starred forms). `\\label` records \
`\\@currentlabel`, which inside a float is set by `\\caption`; a label \
above the caption therefore captures whatever the last `\\refstepcounter` \
left behind — usually the enclosing section number — so `\\ref` silently \
prints a number unrelated to the float. LaTeX gives no warning. Scoped to \
statement-level labels, so the recommended `\\caption{Text\\label{x}}` \
idiom and a `\\subcaptionbox{A\\label{x}}{…}` subfigure label are never \
touched; any earlier caption or hand-rolled \
`\\refstepcounter`/`\\stepcounter` also silences it, and a float with no \
caption is left alone. The fix moves the label to just after the first \
statement-level `\\caption`, and is Unsafe because it changes what `\\ref` \
prints (by design) from an inferred intent."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ENVIRONMENT]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(float) = el.as_node() else {
return;
};
let Some(name) = float_name(float) else {
return;
};
let Some(cutoff) = first_stepper(float) else {
return; };
let target = statement_level_captions(float).next();
for label in float.descendants() {
if label.kind() != SyntaxKind::COMMAND {
continue;
}
if command_name(&label).as_deref() != Some("label") {
continue;
}
let start = usize::from(label.text_range().start());
if start >= cutoff {
continue;
}
if !at_statement_level(&label, float) {
continue;
}
let fix = target
.as_ref()
.and_then(|caption| build_fix(&label, caption));
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end: usize::from(label.text_range().end()),
message: format!(
"`\\label` before `\\caption` in this `{name}` captures the enclosing \
counter, not the float number"
),
fix,
related: Vec::new(),
});
}
}
}
fn float_name(env: &SyntaxNode) -> Option<String> {
let name = Environment::cast(env.clone())
.and_then(|e| e.begin())
.and_then(|begin| begin.name())?;
signature::builtin()
.environment(&name)
.filter(|sig| sig.outline == Some(OutlineKind::Float))
.map(|_| name)
}
fn at_statement_level(node: &SyntaxNode, float: &SyntaxNode) -> bool {
let mut cursor = node.parent();
while let Some(current) = cursor {
if ¤t == float {
return true;
}
if current.kind() != SyntaxKind::PARAGRAPH {
return false;
}
cursor = current.parent();
}
false
}
fn first_stepper(float: &SyntaxNode) -> Option<usize> {
float
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.find(|node| {
command_name(node).is_some_and(|name| {
let bare = name.strip_suffix('*').unwrap_or(&name);
CAPTION_COMMANDS.contains(&bare) || COUNTER_STEPPERS.contains(&bare)
})
})
.map(|node| usize::from(node.text_range().start()))
}
fn statement_level_captions(float: &SyntaxNode) -> impl Iterator<Item = SyntaxNode> + '_ {
float.descendants().filter(move |node| {
node.kind() == SyntaxKind::COMMAND
&& command_name(node).is_some_and(|name| {
CAPTION_COMMANDS.contains(&name.strip_suffix('*').unwrap_or(&name))
})
&& at_statement_level(node, float)
})
}
fn build_fix(label: &SyntaxNode, caption: &SyntaxNode) -> Option<Fix> {
let text = label.text().to_string();
let (start, end) = removal_span(label);
let insert_at = usize::from(caption.text_range().end());
if insert_at <= end {
return None;
}
Some(Fix::unsafe_edits(
vec![
crate::linter::diagnostic::Edit::new(start, end, ""),
crate::linter::diagnostic::Edit::new(insert_at, insert_at, text),
],
"move `\\label` after `\\caption`",
))
}
fn removal_span(label: &SyntaxNode) -> (usize, usize) {
let node_span = (
usize::from(label.text_range().start()),
usize::from(label.text_range().end()),
);
let (Some(first), Some(last)) = (label.first_token(), label.last_token()) else {
return node_span;
};
let (line_start, newline_start) = match line_head(&first) {
Some(pair) => pair,
None => return node_span,
};
let mut cursor = last.next_token();
let mut scanned_end = usize::from(last.text_range().end());
loop {
match cursor {
Some(token) if token.kind() == SyntaxKind::WHITESPACE => {
scanned_end = usize::from(token.text_range().end());
cursor = token.next_token();
}
Some(token) if token.kind() == SyntaxKind::NEWLINE => {
return (line_start, usize::from(token.text_range().end()));
}
None => return (newline_start, scanned_end),
_ => return node_span,
}
}
}
fn line_head(first: &SyntaxToken) -> Option<(usize, usize)> {
let mut cursor = first.prev_token();
while let Some(token) = cursor {
match token.kind() {
SyntaxKind::WHITESPACE => cursor = token.prev_token(),
SyntaxKind::NEWLINE => {
let range = token.text_range();
return Some((usize::from(range.end()), usize::from(range.start())));
}
_ => return None,
}
}
Some((0, 0))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(
std::path::Path::new("x.tex"),
&root,
&model,
None,
None,
None,
);
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if LabelBeforeCaption.interests().contains(&el.kind()) {
LabelBeforeCaption.check(&el, &ctx, &mut out);
}
}
out
}
fn fixed(src: &str) -> String {
let out = findings(src);
let fix = out[0].fix.as_ref().expect("expected a fix");
crate::linter::fix::apply_fixes(src, std::slice::from_ref(fix), true).output
}
#[test]
fn flags_label_above_caption_in_a_figure() {
let src = "\\begin{figure}\n \\label{fig:x}\n \\caption{Cap}\n\\end{figure}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "label-before-caption");
assert_eq!(out[0].severity, Severity::Warning);
assert_eq!(&src[out[0].start..out[0].end], "\\label{fig:x}");
}
#[test]
fn flags_in_a_table_and_starred_forms() {
for env in ["table", "figure*", "table*"] {
let src =
format!("\\begin{{{env}}}\n \\label{{a}}\n \\caption{{C}}\n\\end{{{env}}}\n");
assert_eq!(findings(&src).len(), 1, "{env}");
}
}
#[test]
fn silent_when_label_follows_caption() {
let src = "\\begin{figure}\n \\caption{Cap}\n \\label{fig:x}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_outside_a_float() {
let src = "\\begin{center}\n \\label{a}\n \\caption{C}\n\\end{center}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_when_the_float_has_no_caption() {
let src = "\\begin{figure}\n \\includegraphics{a}\n \\label{fig:x}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_on_a_label_inside_the_caption_argument() {
let src = "\\begin{figure}\n \\caption{Cap\\label{fig:x}}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_on_a_label_in_a_command_argument_before_the_caption() {
let src = "\\begin{figure}\n \\subcaptionbox{A\\label{sub:a}}{\\includegraphics{a}}\n \
\\caption{Main}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_on_a_subfigure_label_that_follows_its_own_caption() {
let src = "\\begin{figure}\n \\begin{subfigure}{b}\n \\caption{a}\n \
\\label{sub:a}\n \\end{subfigure}\n \\caption{Main}\n \\label{fig:m}\n\
\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn silent_after_a_manual_refstepcounter() {
let src = "\\begin{figure}\n \\refstepcounter{figure}\n \\label{fig:x}\n \
\\caption{Cap}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn flags_each_offending_label() {
let src = "\\begin{figure}\n \\label{a}\n \\label{b}\n \\caption{C}\n\\end{figure}\n";
assert_eq!(findings(src).len(), 2);
}
#[test]
fn fix_moves_the_label_after_the_caption_and_removes_the_line() {
let src = "\\begin{figure}\n \\includegraphics{a}\n \\label{fig:x}\n \
\\caption{Cap}\n\\end{figure}\n";
let out = findings(src);
assert_eq!(
out[0].fix.as_ref().unwrap().applicability,
crate::linter::diagnostic::Applicability::Unsafe
);
assert_eq!(
fixed(src),
"\\begin{figure}\n \\includegraphics{a}\n \\caption{Cap}\\label{fig:x}\n\\end{figure}\n"
);
}
#[test]
fn fix_leaves_no_blank_line_behind() {
let src = "\\begin{figure}\n \\label{a}\n \\caption{C}\n\\end{figure}\n";
let out = fixed(src);
assert!(
!out.contains("\n \n") && !out.contains("\n\n"),
"blank line left behind: {out:?}"
);
}
#[test]
fn fix_swaps_an_inline_label_without_touching_the_line() {
let src = "\\begin{figure}\\label{a}\\caption{C}\\end{figure}\n";
assert_eq!(
fixed(src),
"\\begin{figure}\\caption{C}\\label{a}\\end{figure}\n"
);
}
#[test]
fn fix_keeps_other_content_on_the_label_line() {
let src = "\\begin{figure}\n x \\label{a} y\n \\caption{C}\n\\end{figure}\n";
assert_eq!(
fixed(src),
"\\begin{figure}\n x y\n \\caption{C}\\label{a}\n\\end{figure}\n"
);
}
#[test]
fn reports_without_a_fix_when_only_a_nested_caption_exists() {
let src = "\\begin{figure}\n \\label{fig:m}\n \\begin{subfigure}{b}\n \
\\caption{a}\n \\end{subfigure}\n\\end{figure}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_none());
}
}