use std::path::PathBuf;
use crate::ast::{command_name, environment_name};
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::semantic::signature::builtin;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "A space before a footnote sets a spurious space before the mark:",
source: "This is important \\footnote{See the appendix.}\n",
}];
const NO_SPACE_COMMANDS: &[&str] = &["footnote", "footnotemark", "index", "label"];
pub struct SpaceBeforeCommand;
impl Rule for SpaceBeforeCommand {
fn id(&self) -> &'static str {
"space-before-command"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a plain space directly before a command that should hug the \
preceding word -- `\\footnote`, `\\footnotemark`, `\\index`, `\\label` \
(ChkTeX 24/42). A space before `\\footnote` sets a spurious space before \
the footnote mark (`word \\footnote{x}` -> \"word ¹\"); a space before a \
zero-width `\\index`/`\\label` leaves a stray inter-word gap that can \
shift the recorded page. The fix deletes the space. It is **unsafe** -- \
removing the space changes the typeset spacing -- so `--fix` leaves it \
alone while `--unsafe-fixes` and the editor code action apply it. To stay \
conservative only the same-line `WORD SPACE \\cmd` shape is flagged (a \
space at line start or after a brace is left alone), and math is skipped \
(an inter-token space is insignificant there), covering both `$…$` and \
math environments like `equation`/`align`."
}
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 !NO_SPACE_COMMANDS.contains(&name.as_str()) {
return;
}
if in_math(command) {
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.prev_token() else {
return;
};
if space.kind() != SyntaxKind::WHITESPACE {
return;
}
if space.prev_token().map(|t| t.kind()) != Some(SyntaxKind::WORD) {
return;
}
let range = space.text_range();
let start = usize::from(range.start());
let end = usize::from(range.end());
let fix = Fix::unsafe_(
start,
end,
"",
format!("Delete the space before `\\{name}`"),
);
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"spurious space before `\\{name}`; delete it so no stray space is typeset before the command"
),
fix: Some(fix),
});
}
}
fn in_math(node: &SyntaxNode) -> bool {
node.ancestors().any(|anc| match anc.kind() {
SyntaxKind::MATH => true,
SyntaxKind::ENVIRONMENT => anc
.children()
.find(|c| c.kind() == SyntaxKind::BEGIN)
.and_then(|begin| environment_name(&begin))
.and_then(|name| builtin().environment(&name).map(|env| env.math))
.unwrap_or(false),
_ => false,
})
}
#[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 SpaceBeforeCommand.interests().contains(&el.kind()) {
SpaceBeforeCommand.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_space_before_footnote_with_unsafe_delete_fix() {
let src = "word \\footnote{x}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "space-before-command");
assert_eq!((out[0].start, out[0].end), (4, 5));
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,
"word\\footnote{x}\n"
);
}
#[test]
fn flags_index_and_label_and_footnotemark() {
assert_eq!(findings("term \\index{term}\n").len(), 1);
assert_eq!(findings("here \\label{sec:x}\n").len(), 1);
assert_eq!(findings("mark \\footnotemark\n").len(), 1);
}
#[test]
fn tight_command_is_clean() {
assert!(findings("word\\footnote{x}\n").is_empty());
}
#[test]
fn multiple_spaces_are_all_deleted() {
let out = findings("word \\footnote{x}\n");
assert_eq!(out.len(), 1);
let fix = out[0].fix.as_ref().unwrap();
assert_eq!((fix.start, fix.end), (4, 6));
assert_eq!(fix.content, "");
}
#[test]
fn command_at_input_start_is_clean() {
assert!(findings("\\footnote{x}\n").is_empty());
}
#[test]
fn after_brace_is_clean() {
assert!(findings("{\\footnote{x}}\n").is_empty());
assert!(findings("\\textbf{a} \\footnote{x}\n").is_empty());
}
#[test]
fn newline_before_command_is_out_of_scope() {
assert!(findings("word\n\\footnote{x}\n").is_empty());
}
#[test]
fn non_targeted_command_is_left_alone() {
assert!(findings("word \\emph{x}\n").is_empty());
}
#[test]
fn inline_math_label_is_left_alone() {
assert!(findings("$x = y \\label{eq}$\n").is_empty());
}
#[test]
fn math_environment_label_is_left_alone() {
let src = "\\begin{equation}\n a = b \\label{eq:1}\n\\end{equation}\n";
assert!(findings(src).is_empty());
}
#[test]
fn flags_each_occurrence() {
assert_eq!(findings("a \\footnote{x} and b \\index{y}\n").len(), 2);
}
}