use std::path::PathBuf;
use crate::ast::command_name;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "A plain space where a tie belongs before a cross-reference:",
source: "see Figure \\ref{fig:plot}\n",
}];
const TIE_COMMANDS: &[&str] = &[
"ref",
"eqref",
"pageref",
"labelcref",
"cite",
"citep",
"citeyearpar",
"parencite",
"Parencite",
"autocite",
"Autocite",
"footcite",
"smartcite",
"Smartcite",
"supercite",
];
pub struct MissingNonbreakingSpace;
impl Rule for MissingNonbreakingSpace {
fn id(&self) -> &'static str {
"missing-nonbreaking-space"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a plain space where a TeX tie (`~`) belongs, before a command whose \
output a line break would orphan: a bare-number reference (`Figure \
\\ref{x}`, `\\eqref`, `\\pageref`) or a bracketed citation (`see \
\\cite{a}`, `\\parencite`, `\\autocite`). A tie keeps the reference on \
the same line. Self-describing references (`\\autoref`, `\\cref`) and \
textual citations (`\\textcite`, `\\citet`) are not flagged -- they emit \
their own noun, so a break orphans nothing. Only the same-line `WORD \
SPACE \\cmd` shape is flagged. The fix is **unsafe** -- inserting a tie \
changes line breaking -- so `--fix` leaves it alone; `--unsafe-fixes` and \
the editor code action apply it."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::COMMAND]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(command) = el.as_node() else {
return;
};
let Some(name) = command_name(command) else {
return;
};
if !TIE_COMMANDS.contains(&name.as_str()) {
return;
}
let Some(ws) = command.first_token().and_then(|t| t.prev_token()) else {
return;
};
if ws.kind() != SyntaxKind::WHITESPACE {
return;
}
if ws.prev_token().map(|t| t.kind()) != Some(SyntaxKind::WORD) {
return;
}
let range = ws.text_range();
let start = usize::from(range.start());
let end = usize::from(range.end());
let fix = Fix::unsafe_(
start,
end,
"~",
format!("Replace the space before `\\{name}` with a non-breaking space `~`"),
);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"missing non-breaking space before `\\{name}`; use a tie `~` so the reference stays on the same line"
),
fix: Some(fix),
});
}
}
#[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 MissingNonbreakingSpace.interests().contains(&el.kind()) {
MissingNonbreakingSpace.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_space_before_ref() {
let out = findings("Figure \\ref{x}\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "missing-nonbreaking-space");
assert_eq!((out[0].start, out[0].end), (6, 7));
}
#[test]
fn flags_space_before_cite() {
assert_eq!(findings("see \\cite{a}\n").len(), 1);
}
#[test]
fn accepts_word_ending_in_period() {
assert_eq!(findings("Eq. \\eqref{z}\n").len(), 1);
}
#[test]
fn tie_already_present_is_clean() {
assert!(findings("Figure~\\ref{x}\n").is_empty());
}
#[test]
fn command_at_input_start_is_clean() {
assert!(findings("\\ref{x}\n").is_empty());
}
#[test]
fn after_brace_is_clean() {
assert!(findings("{\\ref{x}}\n").is_empty());
assert!(findings("\\textbf{a} \\ref{x}\n").is_empty());
}
#[test]
fn nocite_is_not_flagged() {
assert!(findings("foo \\nocite{x}\n").is_empty());
}
#[test]
fn self_describing_refs_are_not_flagged() {
for cmd in ["autoref", "cref", "Cref", "nameref", "vref", "cpageref"] {
assert!(
findings(&format!("in \\{cmd}{{x}}\n")).is_empty(),
"\\{cmd} should not be flagged"
);
}
}
#[test]
fn textual_citations_are_not_flagged() {
for cmd in ["textcite", "Textcite", "citet", "citeauthor", "citeyear"] {
assert!(
findings(&format!("shown by \\{cmd}{{x}}\n")).is_empty(),
"\\{cmd} should not be flagged"
);
}
}
#[test]
fn parenthetical_citations_are_flagged() {
for cmd in ["parencite", "autocite", "footcite", "supercite", "citep"] {
assert_eq!(
findings(&format!("result \\{cmd}{{x}}\n")).len(),
1,
"\\{cmd} should be flagged"
);
}
}
#[test]
fn newline_is_out_of_scope() {
assert!(findings("Figure\n\\ref{x}\n").is_empty());
}
#[test]
fn multiple_spaces_collapse_to_one_tie() {
use crate::linter::diagnostic::Applicability;
let out = findings("Figure \\ref{x}\n");
assert_eq!(out.len(), 1);
let fix = out[0].fix.as_ref().expect("should carry a fix");
assert_eq!((fix.start, fix.end), (6, 8));
assert_eq!(fix.content, "~");
assert_eq!(fix.applicability, Applicability::Unsafe);
}
#[test]
fn carries_unsafe_tie_fix() {
use crate::linter::diagnostic::Applicability;
use crate::linter::fix::apply_fixes;
let src = "Figure \\ref{x}\n";
let out = findings(src);
let fix = out[0].fix.as_ref().expect("should carry a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!((fix.start, fix.end), (out[0].start, out[0].end));
assert_eq!(fix.content, "~");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), true).output,
"Figure~\\ref{x}\n"
);
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), false).applied,
0
);
}
#[test]
fn flags_each_occurrence() {
assert_eq!(findings("Figure \\ref{a} and Table \\ref{b}\n").len(), 2);
}
}