use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxToken};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "An internal `@` macro used without `\\makeatletter`:",
source: "\\my@command\n",
},
Example {
caption: "A leading-`@` macro (a `\\@`-prefixed internal) outside a region:",
source: "\\@ifstar{\\StarredForm}{\\PlainForm}\n",
},
];
pub struct MakeatMacro;
impl Rule for MakeatMacro {
fn id(&self) -> &'static str {
"makeat-macro"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a macro whose name contains `@` (`\\foo@bar`, `\\p@`, \
`\\@ifnextchar`) used outside a `\\makeatletter`/`\\makeatother` region. \
There `@` has its ordinary catcode, so it cannot be part of a control \
word: `\\foo@bar` is read as `\\foo` followed by the text `@bar`, not as a \
call to the internal macro `\\foo@bar`. Usually the enclosing \
`\\makeatletter`/`\\makeatother` was forgotten. Because the formatter's \
lexer already tracks `\\makeatletter` state, this is decided exactly -- an \
in-region name lexes as one token and is never flagged; only the split \
out-of-region form (control word abutting an `@`-word, or `\\@` abutting a \
letter-word) is. Report-only: a correct fix would mean wrapping the use in \
`\\makeatletter`/`\\makeatother`, not a tight local edit, so no autofix is \
offered. The end-of-sentence `\\@` (as in `NASA\\@.`) is not flagged."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::COMMAND, SyntaxKind::CONTROL_SYMBOL]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if let Some(command) = el.as_node() {
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(next) = control_word.next_token() else {
return;
};
if next.kind() == SyntaxKind::WORD
&& next.text().starts_with('@')
&& let Some(run) = at_letter_run(&next)
{
report(self, control_word.text(), &control_word, &next, run, sink);
}
return;
}
let Some(symbol) = el.as_token() else {
return;
};
if symbol.text() != "\\@" {
return;
}
let Some(next) = symbol.next_token() else {
return;
};
if next.kind() == SyntaxKind::WORD
&& let Some(run) = at_letter_run(&next)
{
report(self, symbol.text(), symbol, &next, run, sink);
}
}
}
fn at_letter_run(token: &SyntaxToken) -> Option<usize> {
let len = token
.text()
.bytes()
.take_while(|&b| b == b'@' || b.is_ascii_alphabetic())
.count();
(len > 0).then_some(len)
}
fn report(
rule: &dyn Rule,
head: &str,
head_token: &SyntaxToken,
word: &SyntaxToken,
run: usize,
sink: &mut Vec<Diagnostic>,
) {
let start = usize::from(head_token.text_range().start());
let end = usize::from(word.text_range().start()) + run;
let tail = &word.text()[..run];
sink.push(Diagnostic {
rule: rule.id(),
severity: rule.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"`{head}{tail}` uses `@` in a macro name outside a `\\makeatletter` region; \
`@` is not a letter here, so this reads as `{head}` followed by the text `{tail}`"
),
fix: None,
});
}
#[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 MakeatMacro.interests().contains(&el.kind()) {
MakeatMacro.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_embedded_at_macro() {
let out = findings("\\my@command\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "makeat-macro");
assert!(out[0].fix.is_none(), "report-only");
assert_eq!((out[0].start, out[0].end), (0, 11));
assert!(
out[0].message.contains("`\\my@command`"),
"got: {}",
out[0].message
);
}
#[test]
fn flags_trailing_at_macro() {
let out = findings("\\p@\n");
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (0, 3));
}
#[test]
fn flags_leading_at_control_symbol() {
let out = findings("\\@ifnextchar aX{Y}\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "makeat-macro");
assert_eq!((out[0].start, out[0].end), (0, 12));
assert!(out[0].message.contains("`\\@ifnextchar`"));
}
#[test]
fn flags_double_at_module_prefix() {
let out = findings("\\@@foo\n");
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (0, 6));
}
#[test]
fn in_region_name_is_not_split_so_not_flagged() {
assert!(findings("\\makeatletter\\my@command\\makeatother\n").is_empty());
}
#[test]
fn space_before_at_is_ordinary_text() {
assert!(findings("\\my @command\n").is_empty());
}
#[test]
fn at_after_group_is_ordinary_text() {
assert!(findings("\\foo{x}@bar\n").is_empty());
}
#[test]
fn sentence_spacing_at_is_left_alone() {
assert!(findings("NASA\\@. Next\n").is_empty());
assert!(findings("etc\\@ and more\n").is_empty());
}
#[test]
fn email_in_text_is_not_flagged() {
assert!(findings("Write to user@example.com today.\n").is_empty());
}
#[test]
fn digit_after_control_word_is_not_flagged() {
assert!(findings("\\foo2\n").is_empty());
}
#[test]
fn flags_each_occurrence() {
assert_eq!(findings("\\a@b and \\c@d\n").len(), 2);
}
}