use std::path::PathBuf;
use crate::parser::lexer::is_word_char;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use crate::linter::diagnostic::{Diagnostic, Edit, Fix, Severity};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "Redundant braces around a single-token script argument:",
source: "$x^{2}$ and $y_{\\alpha}$\n",
}];
pub struct RedundantScriptBraces;
impl Rule for RedundantScriptBraces {
fn id(&self) -> &'static str {
"redundant-script-braces"
}
fn emits_fix(&self) -> bool {
true
}
fn default_severity(&self) -> Severity {
Severity::Hint
}
fn description(&self) -> &'static str {
"Flag braces around a single-token sub/superscript argument, which `^`/`_` \
bind without them (`x^{2}` is `x^2`). The autofix deletes the two braces \
and leaves the inner token untouched. It is withheld when dropping the \
braces would let the following character glue onto the argument and change \
meaning (`x^{2}-3` stays braced — unspaced `x^2-3` would re-lex `2-3` as one \
token; `y_{\\alpha}b` stays braced — `\\alphab` is one control word)."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::SUBSCRIPT, SyntaxKind::SUPERSCRIPT]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(script) = el.as_node() else {
return;
};
let Some(group) = script
.children()
.find(|n| n.kind() == SyntaxKind::GROUP)
.filter(strippable_script_arg)
else {
return;
};
let Some(fix) = strip_braces_fix(&group) else {
return;
};
let range = group.text_range();
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message: "redundant braces around a single-token script argument".to_owned(),
fix: Some(fix),
related: Vec::new(),
});
}
}
fn strip_braces_fix(group: &SyntaxNode) -> Option<Fix> {
let mut lbrace = None;
let mut rbrace = None;
for el in group.children_with_tokens() {
if let SyntaxElement::Token(t) = el {
match t.kind() {
SyntaxKind::L_BRACE => lbrace = Some(t.text_range()),
SyntaxKind::R_BRACE => rbrace = Some(t.text_range()),
_ => {}
}
}
}
let (l, r) = (lbrace?, rbrace?);
Some(Fix::safe_edits(
vec![
Edit::new(l.start().into(), l.end().into(), ""),
Edit::new(r.start().into(), r.end().into(), ""),
],
"Remove redundant braces",
))
}
fn strippable_script_arg(group: &SyntaxNode) -> bool {
let mut inner = group.children_with_tokens().filter(|el| {
!matches!(
el.kind(),
SyntaxKind::L_BRACE
| SyntaxKind::R_BRACE
| SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
)
});
let Some(only) = inner.next() else {
return false; };
if inner.next().is_some() {
return false; }
match only {
SyntaxElement::Token(t)
if t.kind() == SyntaxKind::WORD && t.text().chars().count() == 1 =>
{
next_char_safe_after(group, false)
}
SyntaxElement::Node(n) if is_lone_control_word(&n) => next_char_safe_after(group, true),
_ => false,
}
}
fn is_lone_control_word(node: &SyntaxNode) -> bool {
if node.kind() != SyntaxKind::COMMAND {
return false;
}
let mut children = node.children_with_tokens();
let first_is_control_word = matches!(
children.next(),
Some(SyntaxElement::Token(t)) if t.kind() == SyntaxKind::CONTROL_WORD
);
first_is_control_word && children.next().is_none()
}
fn next_char_safe_after(group: &SyntaxNode, letter_only: bool) -> bool {
let Some(next) = group.last_token().and_then(|t| t.next_token()) else {
return true;
};
match next.text().chars().next() {
None => true,
Some(c) if letter_only => !c.is_ascii_alphabetic(),
Some(c) => !is_word_char(c),
}
}
#[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;
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(
std::path::Path::new("x.tex"),
&root,
&model,
None,
None,
None,
);
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if RedundantScriptBraces.interests().contains(&el.kind()) {
RedundantScriptBraces.check(&el, &ctx, &mut out);
}
}
out
}
fn fixed(src: &str) -> String {
let out = findings(src);
let fixes: Vec<_> = out.iter().filter_map(|d| d.fix.clone()).collect();
apply_fixes(src, &fixes, false).output
}
#[test]
fn strips_single_word_and_control_word() {
let out = findings("$x^{2}$ and $y_{\\alpha}$\n");
assert_eq!(out.len(), 2);
assert!(out.iter().all(|d| d.rule == "redundant-script-braces"));
assert!(
out.iter()
.all(|d| d.fix.as_ref().unwrap().applicability == Applicability::Safe)
);
assert_eq!(
fixed("$x^{2}$ and $y_{\\alpha}$\n"),
"$x^2$ and $y_\\alpha$\n"
);
}
#[test]
fn keeps_multichar_argument() {
assert!(findings("$x^{ab}$\n").is_empty());
assert!(findings("$x^{n+1}$\n").is_empty());
}
#[test]
fn keeps_when_a_following_word_char_would_glue() {
assert!(findings("$x^{2}y$\n").is_empty());
assert!(findings("$x^{2}-3$\n").is_empty());
assert!(findings("$y_{i}<z$\n").is_empty());
}
#[test]
fn keeps_control_word_before_letter() {
assert!(findings("$y_{\\alpha}b$\n").is_empty());
}
#[test]
fn strips_control_word_before_nonletter() {
assert_eq!(fixed("$y_{\\alpha}2$\n"), "$y_\\alpha2$\n");
}
#[test]
fn keeps_empty_group() {
assert!(findings("$x^{}$\n").is_empty());
}
#[test]
fn safe_before_math_close_and_command() {
assert_eq!(fixed("$x^{2}\\cdot y$\n"), "$x^2\\cdot y$\n");
}
}