use std::path::PathBuf;
use crate::ast::{AstNode, Environment, command_name, nth_group_text};
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",
},
Example {
caption: "A `\\label` before the first `\\item` has not seen the item counter step:",
source: "\\begin{enumerate}\n \\label{item:first}\n \\item First\n\\end{enumerate}\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 statement that establishes its \
intended counter: the outer `\\caption` in a curated float (`figure`, \
`table`, and their starred forms), or the first `\\item` in the standard \
numbered `enumerate` list. In either position, `\\label` captures the \
previous `\\@currentlabel`—usually an enclosing section number—so \
`\\ref` silently prints an unrelated number. LaTeX gives no warning. \
The list case is limited to statement-level labels before the first item; \
labels after an item may belong to it, while `itemize` and `description` \
items do not step a reference counter. Attached custom item labels and \
complete Beamer overlay markers remain intact. The float case likewise \
skips labels nested in command arguments, and classifies nested counter \
steps conservatively. The fix moves the label just after the proven \
caption or item marker, and is Unsafe because it intentionally changes \
what `\\ref` prints 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(env) = el.as_node() else {
return;
};
if let Some(name) = float_name(env) {
self.check_float(env, &name, sink);
return;
}
if is_numbered_list(env) {
self.check_numbered_list(env, sink);
}
}
}
impl LabelBeforeCaption {
fn check_float(&self, float: &SyntaxNode, name: &str, sink: &mut Vec<Diagnostic>) {
let outer_counter = name.strip_suffix('*').unwrap_or(name);
let Some(cutoff) = outer_counter_cutoff(float, outer_counter) else {
return; };
let target = outer_caption_targets(float, outer_counter).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 the outer `\\caption` in this `{name}` does not \
capture the float number"
),
fix,
related: Vec::new(),
});
}
}
fn check_numbered_list(&self, list: &SyntaxNode, sink: &mut Vec<Diagnostic>) {
let Some(item) = first_statement_item(list) else {
return;
};
let cutoff = usize::from(item.text_range().start());
let target = item_marker_end(&item);
for label in list.descendants() {
if label.kind() != SyntaxKind::COMMAND
|| command_name(&label).as_deref() != Some("label")
{
continue;
}
let start = usize::from(label.text_range().start());
if start >= cutoff || !at_statement_level(&label, list) {
continue;
}
let fix = target.and_then(|insert_at| {
build_move_fix(&label, insert_at, "move `\\label` after the first `\\item`")
});
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end: usize::from(label.text_range().end()),
message: "`\\label` before the first `\\item` in this `enumerate` does not \
capture the item number"
.to_owned(),
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 is_numbered_list(env: &SyntaxNode) -> bool {
let name = Environment::cast(env.clone())
.and_then(|e| e.begin())
.and_then(|begin| begin.name());
name.as_deref() == Some("enumerate")
&& signature::builtin()
.environment("enumerate")
.is_some_and(|sig| sig.list)
}
fn at_statement_level(node: &SyntaxNode, container: &SyntaxNode) -> bool {
let mut cursor = node.parent();
while let Some(current) = cursor {
if ¤t == container {
return true;
}
if current.kind() != SyntaxKind::PARAGRAPH {
return false;
}
cursor = current.parent();
}
false
}
fn first_statement_item(list: &SyntaxNode) -> Option<SyntaxNode> {
list.descendants().find(|node| {
node.kind() == SyntaxKind::COMMAND
&& command_name(node).as_deref() == Some("item")
&& at_statement_level(node, list)
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CounterEffect {
Outer,
Other,
Unknown,
}
fn outer_counter_cutoff(float: &SyntaxNode, outer_counter: &str) -> Option<usize> {
let mut saw_other = false;
for command in float
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
{
match counter_effect(&command, float, outer_counter) {
Some(CounterEffect::Outer | CounterEffect::Unknown) => {
return Some(usize::from(command.text_range().start()));
}
Some(CounterEffect::Other) => saw_other = true,
None => {}
}
}
saw_other.then(|| usize::from(float.text_range().end()))
}
fn outer_caption_targets<'a>(
float: &'a SyntaxNode,
outer_counter: &'a str,
) -> impl Iterator<Item = SyntaxNode> + 'a {
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)
&& counter_effect(node, float, outer_counter) == Some(CounterEffect::Outer)
})
}
fn counter_effect(
command: &SyntaxNode,
float: &SyntaxNode,
outer_counter: &str,
) -> Option<CounterEffect> {
let name = command_name(command)?;
let bare = name.strip_suffix('*').unwrap_or(&name);
if !CAPTION_COMMANDS.contains(&bare) && !COUNTER_STEPPERS.contains(&bare) {
return None;
}
if name.ends_with('*') || bare == "stepcounter" {
return Some(CounterEffect::Unknown);
}
match bare {
"subcaption" | "subcaptionbox" => Some(CounterEffect::Other),
"captionof" | "refstepcounter" => Some(counter_argument_effect(command, outer_counter)),
"caption" | "captionlistentry" | "phantomcaption" => {
Some(caption_scope_effect(command, float))
}
_ => Some(CounterEffect::Unknown),
}
}
fn counter_argument_effect(command: &SyntaxNode, outer_counter: &str) -> CounterEffect {
match nth_group_text(command, 0) {
Some(counter) if counter.trim() == outer_counter => CounterEffect::Outer,
Some(_) => CounterEffect::Other,
None => CounterEffect::Unknown,
}
}
fn caption_scope_effect(command: &SyntaxNode, float: &SyntaxNode) -> CounterEffect {
let mut cursor = command.parent();
while let Some(current) = cursor {
if ¤t == float {
return CounterEffect::Outer;
}
match current.kind() {
SyntaxKind::PARAGRAPH => {}
SyntaxKind::ENVIRONMENT => {
let name = Environment::cast(current.clone())
.and_then(|env| env.begin())
.and_then(|begin| begin.name());
return match name.as_deref() {
Some("subfigure" | "subtable") => CounterEffect::Other,
_ => CounterEffect::Unknown,
};
}
_ => return CounterEffect::Unknown,
}
cursor = current.parent();
}
CounterEffect::Unknown
}
fn item_marker_end(item: &SyntaxNode) -> Option<usize> {
let (mut end, has_attached_body) = item_command_marker_end(item)?;
if has_attached_body {
return Some(end);
}
let parent = item.parent()?;
let siblings: Vec<SyntaxElement> = parent.children_with_tokens().collect();
let mut index = siblings
.iter()
.position(|element| element.as_node() == Some(item))?
+ 1;
if let Some((next, suffix_end)) = angle_marker_suffix(&siblings, index).ok()? {
index = next;
end = suffix_end;
}
if let Some((next, suffix_end)) = bracket_marker_suffix(&siblings, index).ok()? {
index = next;
end = suffix_end;
}
if let Some((_, suffix_end)) = angle_marker_suffix(&siblings, index).ok()? {
end = suffix_end;
}
Some(end)
}
fn item_command_marker_end(item: &SyntaxNode) -> Option<(usize, bool)> {
let mut end = None;
let mut saw_control_word = false;
for child in item.children_with_tokens() {
match child.kind() {
SyntaxKind::DOC_COMMENT => {}
SyntaxKind::CONTROL_WORD => {
saw_control_word = true;
end = Some(usize::from(child.text_range().end()));
}
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE if saw_control_word => {}
SyntaxKind::OPTIONAL if saw_control_word => {
end = Some(usize::from(child.text_range().end()));
}
_ if saw_control_word => return end.map(|end| (end, true)),
_ => {}
}
}
end.map(|end| (end, false))
}
fn angle_marker_suffix(
elements: &[SyntaxElement],
start: usize,
) -> Result<Option<(usize, usize)>, ()> {
let mut index = skip_marker_trivia(elements, start);
let first = match elements.get(index) {
Some(first) if first.kind() == SyntaxKind::COMMENT => return Err(()),
Some(first) => element_text(first),
None => return Ok(None),
};
if !first.starts_with('<') {
return Ok(None);
}
loop {
let element = elements.get(index).ok_or(())?;
if element.kind() == SyntaxKind::COMMENT {
return Err(());
}
let closes = !matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE)
&& element_text(element).ends_with('>');
index += 1;
if closes {
return Ok(Some((index, usize::from(element.text_range().end()))));
}
}
}
fn bracket_marker_suffix(
elements: &[SyntaxElement],
start: usize,
) -> Result<Option<(usize, usize)>, ()> {
let mut index = skip_marker_trivia(elements, start);
let first = match elements.get(index) {
Some(first) if first.kind() == SyntaxKind::COMMENT => return Err(()),
Some(first) => first,
None => return Ok(None),
};
if first.kind() == SyntaxKind::OPTIONAL {
return Ok(Some((index + 1, usize::from(first.text_range().end()))));
}
if first.kind() != SyntaxKind::L_BRACKET {
return Ok(None);
}
let mut depth = 0usize;
loop {
let element = elements.get(index).ok_or(())?;
match element.kind() {
SyntaxKind::L_BRACKET => depth += 1,
SyntaxKind::R_BRACKET => depth = depth.checked_sub(1).ok_or(())?,
SyntaxKind::COMMENT => return Err(()),
_ => {}
}
index += 1;
if depth == 0 {
return Ok(Some((index, usize::from(element.text_range().end()))));
}
}
}
fn skip_marker_trivia(elements: &[SyntaxElement], mut index: usize) -> usize {
while elements.get(index).is_some_and(|element| {
matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE)
}) {
index += 1;
}
index
}
fn element_text(element: &SyntaxElement) -> String {
match element {
SyntaxElement::Node(node) => node.text().to_string(),
SyntaxElement::Token(token) => token.text().to_owned(),
}
}
fn build_fix(label: &SyntaxNode, caption: &SyntaxNode) -> Option<Fix> {
let insert_at = usize::from(caption.text_range().end());
build_move_fix(label, insert_at, "move `\\label` after `\\caption`")
}
fn build_move_fix(label: &SyntaxNode, insert_at: usize, description: &str) -> Option<Fix> {
let text = label.text().to_string();
let (start, end) = removal_span(label);
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),
],
description,
))
}
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 flags_a_statement_level_label_before_the_first_enumerate_item() {
let src = "\\begin{enumerate}\n \\label{item:first}\n \\item First\n\\end{enumerate}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "\\label{item:first}");
assert!(out[0].message.contains("`\\item`"));
}
#[test]
fn list_gate_is_silent_after_an_item_and_below_statement_level() {
for src in [
"\\begin{enumerate}\n \\item First\n \\label{item:first}\n \\item Second\n\\end{enumerate}\n",
"\\begin{enumerate}\n {\\label{item:first}}\n \\item First\n\\end{enumerate}\n",
"\\begin{enumerate}\n \\textbf{\\label{item:first}}\n \\item First\n\\end{enumerate}\n",
] {
assert!(findings(src).is_empty(), "{src}");
}
}
#[test]
fn list_gate_is_silent_for_non_numbered_lists() {
for env in ["itemize", "description"] {
let src = format!(
"\\begin{{{env}}}\n \\label{{item:first}}\n \\item[Term] First\n\\end{{{env}}}\n"
);
assert!(findings(&src).is_empty(), "{env}");
}
}
#[test]
fn nested_items_do_not_supply_an_outer_list_target() {
let src = "\\begin{enumerate}\n \\label{outer}\n \\begin{enumerate}\n \\item Inner\n \\end{enumerate}\n\\end{enumerate}\n";
assert!(findings(src).is_empty());
}
#[test]
fn list_fix_moves_the_label_after_the_complete_item_marker() {
for (src, expected) in [
(
"\\begin{enumerate}\n \\label{item:first}\n \\item[(a)] First\n\\end{enumerate}\n",
"\\begin{enumerate}\n \\item[(a)]\\label{item:first} First\n\\end{enumerate}\n",
),
(
"\\begin{enumerate}\n \\label{item:first}\n \\item<2->[custom]<3-> First\n\\end{enumerate}\n",
"\\begin{enumerate}\n \\item<2->[custom]<3->\\label{item:first} First\n\\end{enumerate}\n",
),
(
"\\begin{enumerate}\n \\label{item:first}\n \\item {First}\n\\end{enumerate}\n",
"\\begin{enumerate}\n \\item\\label{item:first} {First}\n\\end{enumerate}\n",
),
] {
assert_eq!(fixed(src), expected);
}
}
#[test]
fn incomplete_item_overlay_is_reported_without_a_fix() {
let src =
"\\begin{enumerate}\n \\label{item:first}\n \\item<2- First\n\\end{enumerate}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_none());
}
#[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 flags_outer_label_after_a_nested_subcaption() {
for (outer, inner) in [("figure", "subfigure"), ("table", "subtable")] {
let src = format!(
"\\begin{{{outer}}}\n \\begin{{{inner}}}{{b}}\n \\caption{{Sub}}\n \\end{{{inner}}}\n \\label{{outer:x}}\n \\caption{{Outer}}\n\\end{{{outer}}}\n"
);
let out = findings(&src);
assert_eq!(out.len(), 1, "{outer}/{inner}");
assert_eq!(&src[out[0].start..out[0].end], "\\label{outer:x}");
}
}
#[test]
fn fix_targets_the_outer_caption_after_a_nested_subcaption() {
let src = "\\begin{figure}\n \\begin{subfigure}{b}\n \\caption{Sub}\n \\end{subfigure}\n \\label{fig:x}\n \\caption{Outer}\n\\end{figure}\n";
assert_eq!(
fixed(src),
"\\begin{figure}\n \\begin{subfigure}{b}\n \\caption{Sub}\n \\end{subfigure}\n \\caption{Outer}\\label{fig:x}\n\\end{figure}\n"
);
}
#[test]
fn subcaptionbox_does_not_hide_an_outer_label_or_receive_its_fix() {
for src in [
"\\begin{figure}\n \\subcaptionbox{Sub}{x}\n \\label{fig:x}\n \\caption{Outer}\n\\end{figure}\n",
"\\begin{figure}\n \\label{fig:x}\n \\subcaptionbox{Sub}{x}\n \\caption{Outer}\n\\end{figure}\n",
] {
assert_eq!(
fixed(src),
"\\begin{figure}\n \\subcaptionbox{Sub}{x}\n \\caption{Outer}\\label{fig:x}\n\\end{figure}\n"
);
}
}
#[test]
fn silent_after_a_manual_refstepcounter() {
for env in ["figure", "figure*"] {
let src = format!(
"\\begin{{{env}}}\n \\refstepcounter{{figure}}\n \\label{{fig:x}}\n \\caption{{Cap}}\n\\end{{{env}}}\n"
);
assert!(findings(&src).is_empty(), "{env}");
}
}
#[test]
fn a_different_manual_counter_does_not_hide_the_finding() {
let src = "\\begin{figure}\n \\refstepcounter{subfigure}\n \\label{fig:x}\n \
\\caption{Cap}\n\\end{figure}\n";
assert_eq!(findings(src).len(), 1);
}
#[test]
fn an_unknown_manual_counter_remains_a_conservative_barrier() {
let src = "\\begin{figure}\n \\refstepcounter{\\countername}\n \\label{fig:x}\n \
\\caption{Cap}\n\\end{figure}\n";
assert!(findings(src).is_empty());
}
#[test]
fn captionof_is_matched_against_the_outer_counter() {
let matching = "\\begin{figure}\n \\captionof{figure}{Earlier}\n \\label{fig:x}\n \
\\caption{Later}\n\\end{figure}\n";
assert!(findings(matching).is_empty());
let different = "\\begin{figure}\n \\captionof{table}{Table}\n \\label{fig:x}\n \
\\caption{Figure}\n\\end{figure}\n";
assert_eq!(findings(different).len(), 1);
}
#[test]
fn starred_caption_remains_a_conservative_barrier() {
let src = "\\begin{figure}\n \\caption*{Unnumbered}\n \\label{fig:x}\n \
\\caption{Numbered}\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());
}
}