use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "A hard-coded figure number instead of a cross-reference:",
source: "See Figure 3 for the results.\n",
},
Example {
caption: "Even tied with `~`, the number is still hard-coded:",
source: "Table~1 lists the parameters.\n",
},
];
const REFERENCE_WORDS: &[&str] = &[
"Figure",
"Fig.",
"Table",
"Tab.",
"Section",
"Sec.",
"Chapter",
"Chap.",
"Equation",
"Eq.",
"Appendix",
"Part",
"Algorithm",
"Alg.",
"Listing",
"Theorem",
"Lemma",
"Definition",
"Corollary",
"Proposition",
];
pub struct HardCodedReference;
impl Rule for HardCodedReference {
fn id(&self) -> &'static str {
"hard-coded-reference"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a literal cross-reference written in prose -- `Figure 3`, `Table~1`, \
`Section 2` -- instead of `\\ref`/`\\cref` to a `\\label` (textidote \
sh:hcfig/hctab/hcsec). Hard-coding the number defeats LaTeX's automatic \
numbering: renumbering a float or reordering sections silently breaks the \
reference and drops the hyperlink. The rule is **report-only** -- the \
correct rewrite needs the label the number refers to, which is not in the \
text, so no autofix is offered. To stay conservative it fires only for a \
capitalized reference word (`Figure`, `Table`, `Section`, `Eq.`, ...) \
matched as a whole word and directly followed, across one space or a tie \
`~`, by an arabic number; plurals, lowercase, `Figure~\\ref{x}`, and \
`Figure three` are left alone. It never touches math, comments, or \
verbatim."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::WORD]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(word) = el.as_token() else {
return;
};
let kw = word.text();
if !REFERENCE_WORDS.contains(&kw) {
return;
}
if word
.parent_ancestors()
.any(|node| node.kind() == SyntaxKind::MATH)
{
return;
}
let Some(first) = word.next_token() else {
return;
};
let (number, tie) = if first.kind() == SyntaxKind::TILDE {
(first.next_token(), true)
} else {
let mut tok = Some(first);
let mut newlines = 0;
while let Some(t) = &tok {
match t.kind() {
SyntaxKind::WHITESPACE => {}
SyntaxKind::NEWLINE => newlines += 1,
_ => break,
}
if newlines >= 2 {
return; }
tok = t.next_token();
}
(tok, false)
};
let Some(number) = number else {
return;
};
if number.kind() != SyntaxKind::WORD {
return;
}
let Some(num_len) = numeric_ref_len(number.text()) else {
return;
};
let start = usize::from(word.text_range().start());
let end = usize::from(number.text_range().start()) + num_len;
let sep_display = if tie { "~" } else { " " };
let phrase = format!("{kw}{sep_display}{}", &number.text()[..num_len]);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"hard-coded reference `{phrase}`; use `\\ref`/`\\cref` to a `\\label` so the number stays in sync"
),
fix: None,
});
}
}
fn numeric_ref_len(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_digit) {
return None;
}
if bytes.iter().any(u8::is_ascii_alphabetic) {
return None;
}
let run = bytes
.iter()
.take_while(|&&b| b.is_ascii_digit() || b == b'.')
.count();
Some(text[..run].trim_end_matches('.').len())
}
#[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 {
path: std::path::Path::new("x.tex"),
root: &root,
model: &model,
resolution: None,
citations: None,
};
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if HardCodedReference.interests().contains(&el.kind()) {
HardCodedReference.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_figure_number() {
let src = "See Figure 3 here\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "hard-coded-reference");
assert_eq!(&src[out[0].start..out[0].end], "Figure 3");
assert!(out[0].fix.is_none());
}
#[test]
fn flags_tie_separated_number() {
let src = "Table~1 lists them\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "Table~1");
}
#[test]
fn flags_dotted_abbreviation() {
assert_eq!(findings("as in Fig. 3 above\n").len(), 1);
assert_eq!(findings("see Eq.~2 for this\n").len(), 1);
}
#[test]
fn ref_command_is_left_alone() {
assert!(findings("Figure~\\ref{fig:x} shows\n").is_empty());
assert!(findings("see Figure \\ref{fig:x}\n").is_empty());
}
#[test]
fn lowercase_word_is_left_alone() {
assert!(findings("in figure 3 we plot\n").is_empty());
}
#[test]
fn plural_and_glued_forms_are_left_alone() {
assert!(findings("Figures 3 and 4\n").is_empty());
assert!(findings("(Figure 3)\n").is_empty());
}
#[test]
fn spelled_out_number_is_left_alone() {
assert!(findings("Figure three shows\n").is_empty());
}
#[test]
fn bare_reference_word_is_left_alone() {
assert!(findings("the Figure shows a plot\n").is_empty());
}
#[test]
fn subpart_and_version_numbers_are_left_alone() {
assert!(findings("Figure 3a shows\n").is_empty());
}
#[test]
fn trailing_punctuation_stays_out_of_span() {
let src = "See Figure 3, which\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "Figure 3");
}
#[test]
fn decimal_number_is_flagged() {
let src = "See Section 3.2 for\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "Section 3.2");
}
#[test]
fn paragraph_break_is_left_alone() {
assert!(findings("Figure\n\n3\n").is_empty());
}
#[test]
fn single_line_break_is_flagged() {
assert_eq!(findings("see Figure\n3 here\n").len(), 1);
}
#[test]
fn math_is_left_alone() {
assert!(findings("$Section 2$\n").is_empty());
}
#[test]
fn flags_each_occurrence() {
assert_eq!(findings("Figure 3 and Table 4\n").len(), 2);
}
}