use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxKind, SyntaxToken};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "A lowercase abbreviation followed by more text takes an interword space:",
source: "We tried several methods, e.g. gradient descent.\n",
},
Example {
caption: "An acronym ending a sentence takes intersentence spacing:",
source: "The rover reached the USA. Then it stopped.\n",
},
];
const INTERWORD_ABBREVS: &[&str] = &["e.g.", "i.e.", "etc.", "cf.", "vs.", "viz."];
pub struct AbbreviationSpacing;
impl Rule for AbbreviationSpacing {
fn id(&self) -> &'static str {
"abbreviation-spacing"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag TeX's sentence-vs-interword spacing going wrong around abbreviations \
and acronyms (ChkTeX 12/13). Outside `\\frenchspacing`, TeX widens the \
space after `.`/`?`/`!` unless the punctuation follows an uppercase \
letter. Two shapes defeat that: a lowercase abbreviation (`e.g.`, `i.e.`, \
`etc.`, `et al.`) gets a too-wide space, fixed with `\\ ` (`e.g.\\ foo`); \
and an uppercase acronym ending a sentence (`USA.`) gets a too-narrow \
space, fixed with `\\@` (`USA\\@.`). To stay conservative the first fires \
only before a lowercase word (the sentence clearly continues) and the \
second only for a run of two or more capitals before the period and \
before an uppercase word (a new sentence), so initials (`J.`), dotted \
forms (`U.S.A.`), and mid-sentence acronyms are left alone. Both fixes are \
**unsafe** -- they change the typeset spacing -- so `--fix` leaves them \
alone while `--unsafe-fixes` and the editor code action apply them. The \
rule is silent under `\\frenchspacing`, and never touches comments, \
verbatim, or math."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let mut french = false;
for el in ctx.root.descendants_with_tokens() {
let Some(tok) = el.as_token() else {
continue;
};
match tok.kind() {
SyntaxKind::CONTROL_WORD => match tok.text() {
"\\frenchspacing" => french = true,
"\\nonfrenchspacing" => french = false,
_ => {}
},
SyntaxKind::WORD if !french => {
if tok
.parent_ancestors()
.any(|node| node.kind() == SyntaxKind::MATH)
{
continue;
}
self.check_word(tok, sink);
}
_ => {}
}
}
}
}
impl AbbreviationSpacing {
fn check_word(&self, word: &SyntaxToken, sink: &mut Vec<Diagnostic>) {
let text = word.text();
let base = usize::from(word.text_range().start());
if let Some(abbrev) = matched_interword_abbrev(word)
&& let Some(ws) = word.next_token()
&& ws.kind() == SyntaxKind::WHITESPACE
&& ws
.next_token()
.is_some_and(|next| starts_with_ascii_lower(&next))
{
let start = usize::from(ws.text_range().start());
let end = usize::from(ws.text_range().end());
let fix = Fix::unsafe_(
start,
end,
"\\ ",
format!("Replace the space after `{abbrev}` with an interword space `\\ `"),
);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"`{abbrev}` is an abbreviation, not a sentence end; use an interword space `\\ ` (`{abbrev}\\ `) so TeX does not widen the gap"
),
fix: Some(fix),
});
return;
}
if ends_with_acronym_punct(text)
&& let Some(ws) = word.next_token()
&& ws.kind() == SyntaxKind::WHITESPACE
&& ws
.next_token()
.is_some_and(|next| starts_with_ascii_upper(&next))
{
let punct = base + text.len() - 1;
let start = punct;
let end = base + text.len();
let fix = Fix::unsafe_(
punct,
punct,
"\\@",
"Insert `\\@` before the sentence-ending punctuation so TeX widens the gap",
);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message:
"capital before sentence-ending punctuation suppresses intersentence spacing; use `\\@` (`Word\\@.`) to restore it"
.to_owned(),
fix: Some(fix),
});
}
}
}
fn matched_interword_abbrev(word: &SyntaxToken) -> Option<&'static str> {
let text = word.text();
for &abbrev in INTERWORD_ABBREVS {
if let Some(prefix) = text.strip_suffix(abbrev)
&& prefix
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric())
{
return Some(abbrev);
}
}
if let Some(prefix) = text.strip_suffix("al.")
&& prefix
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric())
&& prev_content_word_is(word, "et")
{
return Some("et al.");
}
None
}
fn prev_content_word_is(word: &SyntaxToken, expected: &str) -> bool {
let Some(ws) = word.prev_token() else {
return false;
};
if ws.kind() != SyntaxKind::WHITESPACE {
return false;
}
ws.prev_token()
.is_some_and(|prev| prev.kind() == SyntaxKind::WORD && prev.text() == expected)
}
fn ends_with_acronym_punct(text: &str) -> bool {
let Some(body) = text
.strip_suffix('.')
.or_else(|| text.strip_suffix('?'))
.or_else(|| text.strip_suffix('!'))
else {
return false;
};
let uppercase_run = body
.chars()
.rev()
.take_while(|c| c.is_ascii_uppercase())
.count();
uppercase_run >= 2
}
fn starts_with_ascii_lower(tok: &SyntaxToken) -> bool {
tok.kind() == SyntaxKind::WORD
&& tok
.text()
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase())
}
fn starts_with_ascii_upper(tok: &SyntaxToken) -> bool {
tok.kind() == SyntaxKind::WORD
&& tok
.text()
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::diagnostic::Applicability;
use crate::linter::fix::apply_fixes;
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();
AbbreviationSpacing.check_file(&ctx, &mut out);
out
}
#[test]
fn flags_interword_abbrev_with_unsafe_fix() {
let src = "see e.g. foo\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "abbreviation-spacing");
assert_eq!((out[0].start, out[0].end), (8, 9));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(fix.content, "\\ ");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), false).applied,
0
);
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), true).output,
"see e.g.\\ foo\n"
);
}
#[test]
fn flags_ie_and_etc() {
assert_eq!(findings("so i.e. bar\n").len(), 1);
assert_eq!(findings("apples, etc. and more\n").len(), 1);
}
#[test]
fn flags_et_al() {
let out = findings("Smith et al. showed this\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("et al."));
}
#[test]
fn abbrev_before_capital_is_left_alone() {
assert!(findings("and so on, etc. The next\n").is_empty());
}
#[test]
fn abbrev_in_parens_is_flagged() {
assert_eq!(findings("methods (e.g. foo) work\n").len(), 1);
}
#[test]
fn word_ending_in_al_without_et_is_clean() {
assert!(findings("the cal. value here\n").is_empty());
assert!(findings("the al. value here\n").is_empty());
}
#[test]
fn already_interword_is_clean() {
assert!(findings("see e.g.\\ foo\n").is_empty());
}
#[test]
fn flags_acronym_intersentence_with_unsafe_fix() {
let src = "the USA. Then we left\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (7, 8));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(fix.content, "\\@");
assert_eq!((fix.start, fix.end), (7, 7));
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), true).output,
"the USA\\@. Then we left\n"
);
}
#[test]
fn flags_acronym_with_question_mark() {
assert_eq!(findings("Was it the FBI? They wondered\n").len(), 1);
}
#[test]
fn single_capital_initial_is_left_alone() {
assert!(findings("From J. Smith we heard\n").is_empty());
}
#[test]
fn dotted_acronym_is_left_alone() {
assert!(findings("the U.S.A. Then home\n").is_empty());
}
#[test]
fn acronym_before_lowercase_is_left_alone() {
assert!(findings("the USA. government spends\n").is_empty());
}
#[test]
fn frenchspacing_suppresses_the_rule() {
assert!(findings("\\frenchspacing see e.g. foo\n").is_empty());
assert!(findings("\\frenchspacing the USA. Then home\n").is_empty());
}
#[test]
fn nonfrenchspacing_reenables_the_rule() {
assert_eq!(
findings("\\frenchspacing off \\nonfrenchspacing see e.g. foo\n").len(),
1
);
}
#[test]
fn math_is_left_alone() {
assert!(findings("$e.g. x$\n").is_empty());
}
#[test]
fn flags_each_occurrence() {
let out = findings("see e.g. foo and i.e. bar\n");
assert_eq!(out.len(), 2);
}
}