use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxToken};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "Literal dots in text:",
source: "See Chapter 2, 3, ... for details.\n",
},
Example {
caption: "Literal dots in a math sum (an operator neighbor picks `\\cdots`):",
source: "$a_1 + ... + a_n$\n",
},
];
pub struct Ellipsis;
impl Rule for Ellipsis {
fn id(&self) -> &'static str {
"ellipsis"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a literal run of three or more periods (`...`) where a real \
ellipsis command belongs. `...` sets three tight full stops; LaTeX's \
ellipsis commands set correctly spaced dots. In text the fix is a \
**safe** swap to `\\dots` (a space is added before a following letter so \
the control word cannot glue onto the next word). In math `\\ldots` \
(baseline, for comma lists) and `\\cdots` (centered, for operator \
chains) are not interchangeable, so the fix is **unsafe**: it guesses \
from the neighboring atoms -- an operator or relation picks `\\cdots`, \
otherwise `\\ldots` -- and applies only under `--unsafe-fixes` or as an \
editor code action. Comments and verbatim are never touched."
}
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();
if !text.contains("...") {
return;
}
let in_math = tok
.parent_ancestors()
.any(|node| node.kind() == SyntaxKind::MATH);
let base = usize::from(tok.text_range().start());
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'.' {
i += 1;
continue;
}
let run_start = i;
while i < bytes.len() && bytes[i] == b'.' {
i += 1;
}
if i - run_start < 3 {
continue;
}
let run_end = i;
let needs_space = text[run_end..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic());
let (command, applicability) = if in_math {
(math_command(tok, text, run_start, run_end), false)
} else {
("\\dots", true)
};
let replacement = if needs_space {
format!("{command} ")
} else {
command.to_owned()
};
let start = base + run_start;
let end = base + run_end;
let description = format!("Replace `...` with `{command}`");
let fix = if applicability {
Fix::safe(start, end, replacement, description)
} else {
Fix::unsafe_(start, end, replacement, description)
};
let message = if in_math {
format!(
"literal `...` ellipsis; use `{command}` in math (`\\ldots` for lists, `\\cdots` for operator chains)"
)
} else {
"literal `...` ellipsis; use `\\dots`".to_owned()
};
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message,
fix: Some(fix),
});
}
}
}
fn math_command(tok: &SyntaxToken, text: &str, run_start: usize, run_end: usize) -> &'static str {
let before = if run_start > 0 {
text[..run_start].chars().next_back()
} else {
neighbor_char(tok.prev_token(), SyntaxToken::prev_token, |t| {
t.chars().next_back()
})
};
let after = if run_end < text.len() {
text[run_end..].chars().next()
} else {
neighbor_char(tok.next_token(), SyntaxToken::next_token, |t| {
t.chars().next()
})
};
if is_operator(before) || is_operator(after) {
"\\cdots"
} else {
"\\ldots"
}
}
fn neighbor_char(
first: Option<SyntaxToken>,
step: fn(&SyntaxToken) -> Option<SyntaxToken>,
pick: fn(&str) -> Option<char>,
) -> Option<char> {
let mut cur = first;
while let Some(tok) = cur {
match tok.kind() {
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT => {
cur = step(&tok);
}
_ => return pick(tok.text()),
}
}
None
}
fn is_operator(c: Option<char>) -> bool {
matches!(c, Some('+' | '-' | '*' | '/' | '=' | '<' | '>'))
}
#[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 Ellipsis.interests().contains(&el.kind()) {
Ellipsis.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_text_ellipsis_with_safe_dots_fix() {
let src = "one, two, ...\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "ellipsis");
assert_eq!((out[0].start, out[0].end), (10, 13));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Safe);
assert_eq!(fix.content, "\\dots");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), false).output,
"one, two, \\dots\n"
);
}
#[test]
fn adds_space_before_following_letter() {
let src = "foo...bar\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (3, 6));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.content, "\\dots ");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), false).output,
"foo\\dots bar\n"
);
}
#[test]
fn math_operator_neighbor_picks_cdots_unsafe() {
let src = "$a + ... + b$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(fix.content, "\\cdots");
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 + \\cdots + b$\n"
);
}
#[test]
fn math_comma_list_picks_ldots() {
let out = findings("$a,...,b$\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].fix.as_ref().unwrap().content, "\\ldots");
}
#[test]
fn math_letter_neighbors_default_to_ldots_with_space() {
let out = findings("$1...n$\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].fix.as_ref().unwrap().content, "\\ldots ");
}
#[test]
fn two_dots_are_not_flagged() {
assert!(findings("wait.. what\n").is_empty());
}
#[test]
fn existing_commands_are_clean() {
assert!(findings("a\\dots b and $x_1,\\ldots,x_n$\n").is_empty());
}
#[test]
fn flags_each_run() {
assert_eq!(findings("a... b...\n").len(), 2);
}
}