use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "A bare function name typesets as italic variables:",
source: "$sin x + cos x = 1$\n",
},
Example {
caption: "It fires through the glued `f(x)` form too:",
source: "The limit $lim(x)$ diverges.\n",
},
];
const OPERATORS: &[&str] = &[
"arccos", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "deg", "det", "dim",
"exp", "gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min",
"Pr", "sec", "sin", "sinh", "sup", "tan", "tanh",
];
pub struct MathOperatorName;
impl Rule for MathOperatorName {
fn id(&self) -> &'static str {
"math-operator-name"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a bare log-like function name (`sin`, `cos`, `log`, `lim`, and the \
rest of the LaTeX/amsmath set) written in math mode without its \
backslash, so TeX sets it as italic variables instead of the upright \
`\\sin` operator with correct spacing (ChkTeX 35). It fires when the name \
starts a `WORD` and ends at a word boundary, catching both `$sin x$` and \
the glued `$sin(x)$`, while leaving words that merely begin with one \
(`since`) alone and preferring the longest match (`sinh` over `sin`). To \
stay conservative it only fires inside math mode and never inside a \
subscript or superscript, where `max` in `x_{max}` is almost always a \
label rather than the operator. The fix inserts the backslash \
(`sin` -> `\\sin`); it is **unsafe** because it changes the typeset output \
(upright glyph and operator spacing) and a bare `sin` is occasionally a \
real product, 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::WORD]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(tok) = el.as_token() else {
return;
};
let mut in_math = false;
for node in tok.parent_ancestors() {
match node.kind() {
SyntaxKind::SUBSCRIPT | SyntaxKind::SUPERSCRIPT => return,
SyntaxKind::MATH => {
in_math = true;
break;
}
_ => {}
}
}
if !in_math {
return;
}
let Some(name) = match_operator_prefix(tok.text()) else {
return;
};
let base = usize::from(tok.text_range().start());
let start = base;
let end = base + name.len();
let content = format!("\\{name}");
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!("bare `{name}` in math typesets as italic variables; use `\\{name}`"),
fix: Some(Fix::unsafe_(
start,
end,
content,
format!("Replace `{name}` with `\\{name}`"),
)),
});
}
}
fn match_operator_prefix(text: &str) -> Option<&'static str> {
let bytes = text.as_bytes();
OPERATORS
.iter()
.copied()
.filter(|op| {
let n = op.len();
bytes.len() >= n
&& &bytes[..n] == op.as_bytes()
&& bytes.get(n).is_none_or(|b| !b.is_ascii_alphabetic())
})
.max_by_key(|op| op.len())
}
#[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 MathOperatorName.interests().contains(&el.kind()) {
MathOperatorName.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_bare_operator_with_unsafe_fix() {
let src = "$sin x$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "math-operator-name");
assert_eq!((out[0].start, out[0].end), (1, 4));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(fix.content, "\\sin");
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,
"$\\sin x$\n"
);
}
#[test]
fn flags_glued_paren_form_fixing_only_the_name() {
let src = "$sin(x)$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (1, 4));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), true).output,
"$\\sin(x)$\n"
);
}
#[test]
fn flags_each_operator_in_a_relation() {
let out = findings("$sin x + cos x = 1$\n");
assert_eq!(out.len(), 2);
}
#[test]
fn command_form_is_fine() {
assert!(findings("$\\sin x + \\cos x$\n").is_empty());
}
#[test]
fn outside_math_is_left_alone() {
assert!(findings("It was a sin to log this.\n").is_empty());
}
#[test]
fn word_that_only_starts_with_operator_is_left_alone() {
assert!(findings("$since$\n").is_empty());
}
#[test]
fn prefers_longest_operator() {
let src = "$sinh x$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (1, 5));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.content, "\\sinh");
}
#[test]
fn subscript_label_is_left_alone() {
assert!(findings("$x_{max}$\n").is_empty());
}
#[test]
fn lim_base_before_subscript_is_flagged() {
let out = findings("$lim_{n} a_n$\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("lim"));
}
}