use std::path::PathBuf;
use rowan::NodeOrToken;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
use crate::linter::diagnostic::{Diagnostic, Severity};
use super::{Rule, RuleContext};
const OPENERS: &[&str] = &[
"(", "[", "\\{", "\\lbrace", "\\lbrack", "\\langle", "\\lceil", "\\lfloor", "\\lgroup",
"\\lvert", "\\lVert",
];
const CLOSERS: &[&str] = &[
")", "]", "\\}", "\\rbrace", "\\rbrack", "\\rangle", "\\rceil", "\\rfloor", "\\rgroup",
"\\rvert", "\\rVert",
];
pub struct MismatchedDelimiter;
impl Rule for MismatchedDelimiter {
fn id(&self) -> &'static str {
"mismatched-delimiter"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::LEFT_RIGHT]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(pair) = el.as_node() else {
return;
};
let (open, close) = delimiters(pair);
let (Some(open), Some(close)) = (open, close) else {
return;
};
if CLOSERS.contains(&open.text()) {
sink.push(orientation_diag(
self,
&open,
format!(
"`\\left{}` uses a closing delimiter where an opening one is expected",
open.text()
),
));
}
if OPENERS.contains(&close.text()) {
sink.push(orientation_diag(
self,
&close,
format!(
"`\\right{}` uses an opening delimiter where a closing one is expected",
close.text()
),
));
}
}
}
fn orientation_diag(
rule: &MismatchedDelimiter,
delim: &SyntaxToken,
message: String,
) -> Diagnostic {
let range = delim.text_range();
Diagnostic {
rule: rule.id(),
severity: rule.default_severity(),
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message,
fix: None,
}
}
fn delimiters(pair: &SyntaxNode) -> (Option<SyntaxToken>, Option<SyntaxToken>) {
let mut open = None;
let mut close = None;
let mut pending: Option<bool> = None; for element in pair.children_with_tokens() {
match element {
NodeOrToken::Token(token) => {
if is_trivia(token.kind()) {
continue;
}
match token.text() {
"\\left" => pending = Some(true),
"\\right" => pending = Some(false),
_ => match pending.take() {
Some(true) => open = Some(token),
Some(false) => close = Some(token),
None => {}
},
}
}
NodeOrToken::Node(_) => pending = None,
}
}
(open, close)
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
}
#[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 MismatchedDelimiter.interests().contains(&el.kind()) {
MismatchedDelimiter.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_closer_opening_the_pair() {
let out = findings("$\\left) a \\right| $\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert_eq!(out[0].rule, "mismatched-delimiter");
assert!(
out[0].message.contains("\\left)"),
"got: {}",
out[0].message
);
}
#[test]
fn flags_opener_closing_the_pair() {
let out = findings("$\\left| a \\right( $\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(
out[0].message.contains("\\right("),
"got: {}",
out[0].message
);
}
#[test]
fn flags_both_ends_when_both_reversed() {
let out = findings("$\\left) a \\right( $\n");
assert_eq!(out.len(), 2, "got: {out:?}");
}
#[test]
fn matched_pair_is_fine() {
assert!(findings("$\\left( a \\right) $\n").is_empty());
}
#[test]
fn half_open_interval_is_fine() {
assert!(findings("$\\left( a \\right] $\n").is_empty());
}
#[test]
fn null_delimiter_is_fine() {
assert!(findings("$\\left. a \\right) $\n").is_empty());
}
}