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 logo swallows the following space, gluing it to the next word:",
source: "We used \\LaTeX to typeset this.\n",
}];
const TEXT_MACROS: &[&str] = &[
"TeX", "LaTeX", "LaTeXe", "eTeX", "XeTeX", "XeLaTeX", "LuaTeX", "LuaLaTeX", "pdfTeX",
"pdfLaTeX", "BibTeX", "ConTeXt", "MetaPost", "MetaFont", "SliTeX", "PiCTeX", "AmS", "AmSTeX",
"plainTeX",
];
pub struct SwallowedSpace;
impl Rule for SwallowedSpace {
fn id(&self) -> &'static str {
"swallowed-space"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a text-producing control word directly followed by a space that TeX \
eats, gluing the macro's output to the next word (`\\LaTeX is` renders \
\"LaTeXis\") (ChkTeX 1). When TeX tokenizes a control word it discards \
following spaces, so the space never reaches the output. To stay \
conservative the rule fires only for a curated set of argument-less \
TeX-family logos (`\\LaTeX`, `\\TeX`, `\\BibTeX`, ...), only in text mode, \
and only when the next token is a word beginning with an alphanumeric \
character -- a following period (`\\LaTeX .` -> \"LaTeX.\") is what the \
author wanted. The fix inserts `{}` after the control word \
(`\\LaTeX{} is`), ending the macro name so the space survives; it is \
**unsafe** because it changes the typeset output, so `--fix` leaves it \
alone while `--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 !TEXT_MACROS.contains(&name.as_str()) {
return;
}
if command
.ancestors()
.any(|node| node.kind() == SyntaxKind::MATH)
{
return;
}
let Some(control_word) = command
.children_with_tokens()
.filter_map(|e| e.into_token())
.find(|t| t.kind() == SyntaxKind::CONTROL_WORD)
else {
return;
};
let Some(space) = control_word.next_token() else {
return;
};
if space.kind() != SyntaxKind::WHITESPACE {
return;
}
let Some(next) = space.next_token() else {
return;
};
if next.kind() != SyntaxKind::WORD
|| !next
.text()
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric())
{
return;
}
let range = space.text_range();
let start = usize::from(range.start());
let end = usize::from(range.end());
let fix = Fix::unsafe_(
start,
start,
"{}",
format!("Insert `{{}}` after `\\{name}` so the space is not swallowed"),
);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"`\\{name}` swallows the following space; add `{{}}` (`\\{name}{{}}`) or `\\ ` so it prints"
),
fix: Some(fix),
});
}
}
#[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();
for el in root.descendants_with_tokens() {
if SwallowedSpace.interests().contains(&el.kind()) {
SwallowedSpace.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_logo_before_word_with_unsafe_fix() {
let src = "\\LaTeX is nice\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "swallowed-space");
assert_eq!((out[0].start, out[0].end), (6, 7));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!((fix.start, fix.end), (6, 6));
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,
"\\LaTeX{} is nice\n"
);
}
#[test]
fn already_braced_is_clean() {
assert!(findings("\\LaTeX{} is nice\n").is_empty());
}
#[test]
fn escaped_space_is_clean() {
assert!(findings("\\LaTeX\\ is nice\n").is_empty());
}
#[test]
fn tie_is_clean() {
assert!(findings("\\LaTeX~is nice\n").is_empty());
}
#[test]
fn following_punctuation_is_clean() {
assert!(findings("\\LaTeX .\n").is_empty());
}
#[test]
fn following_digit_is_flagged() {
assert_eq!(findings("\\LaTeX 2e\n").len(), 1);
}
#[test]
fn newline_after_logo_is_out_of_scope() {
assert!(findings("\\LaTeX\nis nice\n").is_empty());
}
#[test]
fn trailing_space_at_line_end_is_clean() {
assert!(findings("\\LaTeX \n").is_empty());
}
#[test]
fn non_logo_command_is_left_alone() {
assert!(findings("\\foo bar\n").is_empty());
}
#[test]
fn control_symbol_is_left_alone() {
assert!(findings("100\\% is high\n").is_empty());
}
#[test]
fn math_mode_is_left_alone() {
assert!(findings("$\\TeX x$\n").is_empty());
}
#[test]
fn flags_each_occurrence() {
assert_eq!(findings("\\TeX is old and \\LaTeX is new\n").len(), 2);
}
}