use std::path::PathBuf;
use rowan::NodeOrToken;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use super::{Rule, RuleContext};
pub struct DollarDisplayMath;
impl Rule for DollarDisplayMath {
fn id(&self) -> &'static str {
"dollar-display-math"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::DISPLAY_MATH]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(math) = el.as_node() else {
return;
};
let Some(range) = opening_dollars_range(math) else {
return;
};
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message: "`$$…$$` is plain-TeX display math; use `\\[…\\]`".to_owned(),
fix: delimiter_swap_fix(math, range),
});
}
}
fn delimiter_swap_fix(math: &SyntaxNode, opening: rowan::TextRange) -> Option<Fix> {
let closing = closing_dollars_range(math)?;
let node = math.text_range();
let text = math.text().to_string();
let base = usize::from(node.start());
let body_start = usize::from(opening.end()) - base;
let body_end = usize::from(closing.start()) - base;
let body = &text[body_start..body_end];
Some(Fix::safe(
base,
usize::from(node.end()),
format!("\\[{body}\\]"),
"Replace `$$…$$` with `\\[…\\]`",
))
}
fn opening_dollars_range(math: &SyntaxNode) -> Option<rowan::TextRange> {
let mut dollars = math
.children_with_tokens()
.filter_map(NodeOrToken::into_token)
.take_while(|t| t.kind() == SyntaxKind::DOLLAR);
let first = dollars.next()?;
let second = dollars.next()?;
Some(rowan::TextRange::new(
first.text_range().start(),
second.text_range().end(),
))
}
fn closing_dollars_range(math: &SyntaxNode) -> Option<rowan::TextRange> {
let mut dollars = math
.children_with_tokens()
.filter_map(NodeOrToken::into_token)
.filter(|t| t.kind() == SyntaxKind::DOLLAR);
let trailing: Vec<_> = dollars.by_ref().collect();
let [.., second_last, last] = trailing.as_slice() else {
return None;
};
if trailing.len() < 4 {
return None;
}
Some(rowan::TextRange::new(
second_last.text_range().start(),
last.text_range().end(),
))
}
#[cfg(test)]
mod tests {
use super::*;
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 {
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 DollarDisplayMath.interests().contains(&el.kind()) {
DollarDisplayMath.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_dollar_dollar() {
let out = findings("$$x = y$$\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "dollar-display-math");
assert_eq!((out[0].start, out[0].end), (0, 2));
}
#[test]
fn carries_safe_whole_node_fix() {
use crate::linter::diagnostic::Applicability;
use crate::linter::fix::apply_fixes;
let src = "$$x = y$$\n";
let out = findings(src);
let fix = out[0].fix.as_ref().expect("should carry a fix");
assert_eq!(fix.applicability, Applicability::Safe);
assert_eq!((fix.start, fix.end), (0, 9));
assert_eq!(fix.content, "\\[x = y\\]");
assert_eq!(
apply_fixes(src, std::slice::from_ref(fix), false).output,
"\\[x = y\\]\n"
);
}
#[test]
fn unclosed_display_math_reports_without_a_fix() {
let out = findings("$$x = y\n");
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_none());
}
#[test]
fn bracket_display_is_fine() {
assert!(findings("\\[x = y\\]\n").is_empty());
}
#[test]
fn inline_dollar_is_fine() {
assert!(findings("$x = y$\n").is_empty());
}
}