use std::path::PathBuf;
use crate::ast::command_name;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind};
use super::{Rule, RuleContext};
const TIE_COMMANDS: &[&str] = &[
"cite",
"citep",
"citet",
"citealp",
"citealt",
"citeauthor",
"citeyear",
"citeyearpar",
"parencite",
"Parencite",
"textcite",
"Textcite",
"autocite",
"Autocite",
"footcite",
"smartcite",
"Smartcite",
"supercite",
"fullcite",
"ref",
"eqref",
"cref",
"Cref",
"autoref",
"nameref",
"pageref",
"vref",
"Vref",
"cpageref",
"labelcref",
"crefrange",
"Crefrange",
];
pub struct MissingNonbreakingSpace;
impl Rule for MissingNonbreakingSpace {
fn id(&self) -> &'static str {
"missing-nonbreaking-space"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
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 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);
}
}