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 literal `x` as a multiplication sign in text (fixed to `$\\times$`):",
source: "A 640x200 pixel image.\n",
},
Example {
caption: "The same inside math mode (fixed to `\\times`):",
source: "The grid is $640x200$ cells.\n",
},
];
pub struct TimesVariable;
impl Rule for TimesVariable {
fn id(&self) -> &'static str {
"times-variable"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a literal `x` used as a multiplication sign between two numbers, such \
as `640x200` or `3x3` (ChkTeX 29). TeX sets that `x` as an italic letter \
rather than the `\\times` cross, so it reads wrong. The rule only fires \
when the whole word is `digits x digits` -- one lowercase `x` with ASCII \
digits on both sides and nothing else -- so ordinary words (`matrix`), \
spaced products (`n x m`), and hex literals (`0xFF`, `0x12`) are left \
alone. The fix is **unsafe** (a bare `x` between numbers is usually a \
cross but occasionally a real variable): inside math it rewrites the `x` \
to `\\times`, and in text it wraps it as `$\\times$` so the result still \
compiles. So `--fix` leaves it alone; `--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 text = tok.text();
let Some(xi) = times_x_position(text) else {
return;
};
let base = usize::from(tok.text_range().start());
let start = base + xi;
let end = start + 1;
let in_math = tok
.parent_ancestors()
.any(|node| node.kind() == SyntaxKind::MATH);
let content = if in_math { "\\times" } else { "$\\times$" };
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message:
"literal `x` as a multiplication sign between numbers; use `\\times` for a cross"
.to_owned(),
fix: Some(Fix::unsafe_(
start,
end,
content,
format!("Replace `x` with `{content}`"),
)),
});
}
}
fn times_x_position(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
let xi = bytes.iter().position(|&b| b == b'x')?;
if bytes[xi + 1..].contains(&b'x') {
return None;
}
let (before, after) = (&bytes[..xi], &bytes[xi + 1..]);
if before.is_empty() || after.is_empty() {
return None;
}
if !before.iter().all(u8::is_ascii_digit) || !after.iter().all(u8::is_ascii_digit) {
return None;
}
if before == b"0" {
return None;
}
Some(xi)
}
#[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 TimesVariable.interests().contains(&el.kind()) {
TimesVariable.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_times_in_text_with_unsafe_math_wrapping_fix() {
let src = "A 640x200 image.\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "times-variable");
assert_eq!((out[0].start, out[0].end), (5, 6));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(fix.content, "$\\times$");
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,
"A 640$\\times$200 image.\n"
);
}
#[test]
fn flags_times_in_math_with_bare_times_fix() {
let src = "$640x200$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.content, "\\times");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), true).output,
"$640\\times200$\n"
);
}
#[test]
fn flags_small_product() {
assert_eq!(findings("a 3x3 matrix\n").len(), 1);
}
#[test]
fn ordinary_word_is_left_alone() {
assert!(findings("a matrix and a box\n").is_empty());
}
#[test]
fn spaced_product_is_left_alone() {
assert!(findings("an n x m grid\n").is_empty());
}
#[test]
fn hex_literal_is_left_alone() {
assert!(findings("mask 0xFF here\n").is_empty());
assert!(findings("mask 0x12 here\n").is_empty());
}
#[test]
fn uppercase_x_is_left_alone() {
assert!(findings("a 640X200 image\n").is_empty());
}
#[test]
fn missing_operand_is_left_alone() {
assert!(findings("2x and x3 alone\n").is_empty());
}
#[test]
fn trailing_punctuation_is_left_alone() {
assert!(findings("sized 640x200.\n").is_empty());
}
}