use std::path::PathBuf;
use crate::ast::{command_name, control_word_range, environment_name};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[
Example {
caption: "An inline-math `$` with no matching `$`:",
source: "Let $x = 1 be the base case.\n",
},
Example {
caption: "A display-math `\\[` with no matching `\\]`:",
source: "The bound \\[ x + y follows immediately.\n",
},
Example {
caption: "A `\\left` with no matching `\\right`:",
source: "$a + \\left( b + c$\n",
},
];
pub struct UnclosedMathDelimiter;
impl Rule for UnclosedMathDelimiter {
fn id(&self) -> &'static str {
"unclosed-math-delimiter"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a math opener the parser silently demoted to a plain token because no \
closer was reachable -- a `$` with no matching `$`, a `\\[`/`\\(` with no \
`\\]`/`\\)`, or a `\\left` with no `\\right`. Such a shape is routine data \
in macro code (`>{$}` array columns, `\\expandafter\\@tempa\\[\\@nil`), so \
the parser tolerates it without a diagnostic; in prose it is almost always \
a dropped closer. To stay clear of the macro-code cases the rule is \
conservative: it reports only an opener in document prose, staying silent \
when it sits inside a brace group or optional argument (`\\newcommand{...}{$}`, \
the `>{$}` column spec), an expl3 region, or a `macrocode` body. No autofix: \
the correction (insert a closer, or delete a stray opener) is ambiguous."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[
SyntaxKind::DOLLAR,
SyntaxKind::CONTROL_SYMBOL,
SyntaxKind::COMMAND,
]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
match el {
SyntaxElement::Token(tok) if tok.kind() == SyntaxKind::DOLLAR => {
let Some(parent) = tok.parent() else { return };
if is_math_wrapper(parent.kind()) {
return; }
let range = tok.text_range();
let offset = usize::from(range.start());
if suppressed(&parent, ctx, offset) {
return;
}
push(
self,
sink,
range,
"`$` has no matching `$` (unclosed inline math)",
);
}
SyntaxElement::Token(tok) if tok.kind() == SyntaxKind::CONTROL_SYMBOL => {
let (closer, kind) = match tok.text() {
"\\[" => ("\\]", "display"),
"\\(" => ("\\)", "inline"),
_ => return,
};
let Some(parent) = tok.parent() else { return };
if is_math_wrapper(parent.kind()) {
return;
}
let range = tok.text_range();
let offset = usize::from(range.start());
if suppressed(&parent, ctx, offset) {
return;
}
push(
self,
sink,
range,
&format!(
"`{}` has no matching `{closer}` (unclosed {kind} math)",
tok.text()
),
);
}
SyntaxElement::Node(node) if node.kind() == SyntaxKind::COMMAND => {
if command_name(node).as_deref() != Some("left") {
return;
}
let range = control_word_range(node).unwrap_or_else(|| node.text_range());
let offset = usize::from(range.start());
if !ctx.in_math(offset) || suppressed(node, ctx, offset) {
return;
}
push(self, sink, range, "`\\left` has no matching `\\right`");
}
_ => {}
}
}
}
fn is_math_wrapper(kind: SyntaxKind) -> bool {
matches!(kind, SyntaxKind::INLINE_MATH | SyntaxKind::DISPLAY_MATH)
}
fn suppressed(start: &SyntaxNode, ctx: &RuleContext<'_>, offset: usize) -> bool {
if ctx.in_expl3(offset) {
return true;
}
start.ancestors().any(|n| match n.kind() {
SyntaxKind::GROUP
| SyntaxKind::OPTIONAL
| SyntaxKind::ARGUMENT
| SyntaxKind::NAME_GROUP => true,
SyntaxKind::ENVIRONMENT => is_macrocode_environment(&n),
_ => false,
})
}
fn is_macrocode_environment(env: &SyntaxNode) -> bool {
env.children()
.find(|c| c.kind() == SyntaxKind::BEGIN)
.and_then(|begin| environment_name(&begin))
.is_some_and(|name| name == "macrocode" || name == "macrocode*")
}
fn push(
rule: &UnclosedMathDelimiter,
sink: &mut Vec<Diagnostic>,
range: rowan::TextRange,
message: &str,
) {
sink.push(Diagnostic {
rule: rule.id(),
severity: rule.default_severity(),
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message: message.to_owned(),
fix: None,
related: Vec::new(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::lexer::{LatexFlavor, LexConfig};
use crate::parser::{parse, parse_with_flavor};
use crate::semantic::SemanticModel;
fn findings_of(root: &SyntaxNode) -> Vec<Diagnostic> {
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 UnclosedMathDelimiter.interests().contains(&el.kind()) {
UnclosedMathDelimiter.check(&el, &ctx, &mut out);
}
}
out
}
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
findings_of(&root)
}
fn findings_dtx(src: &str) -> Vec<Diagnostic> {
let cfg = LexConfig {
flavor: LatexFlavor::Package,
dtx: true,
};
let root = SyntaxNode::new_root(parse_with_flavor(src, cfg).green);
findings_of(&root)
}
#[test]
fn flags_unclosed_dollar_in_prose() {
let out = findings("Let $x = 1 be the base.\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert_eq!(out[0].rule, "unclosed-math-delimiter");
assert!(out[0].message.contains('$'), "got: {}", out[0].message);
assert_eq!((out[0].start, out[0].end), (4, 5));
}
#[test]
fn balanced_dollar_in_optional_inside_display_math_is_silent() {
let out = findings("\\[\n \\inferrule*[right=$\\Pi$-eq]\n {A}{B}\n\\]\n");
assert!(out.is_empty(), "got: {out:?}");
}
#[test]
fn flags_unclosed_display_opener() {
let out = findings("The bound \\[ x + y follows.\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(out[0].message.contains("\\["), "got: {}", out[0].message);
assert!(out[0].message.contains("\\]"), "got: {}", out[0].message);
}
#[test]
fn flags_unclosed_inline_paren_opener() {
let out = findings("A value \\( x + y here.\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(out[0].message.contains("\\("), "got: {}", out[0].message);
}
#[test]
fn flags_unclosed_left_inside_real_math() {
let out = findings("$a + \\left( b + c$\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(out[0].message.contains("\\left"), "got: {}", out[0].message);
assert!(
out[0].message.contains("\\right"),
"got: {}",
out[0].message
);
}
#[test]
fn balanced_delimiters_are_fine() {
assert!(findings("$x$\n").is_empty());
assert!(findings("\\[ x + y \\]\n").is_empty());
assert!(findings("\\( x + y \\)\n").is_empty());
assert!(findings("$\\left( x \\right)$\n").is_empty());
assert!(findings("$$x$$\n").is_empty());
}
#[test]
fn array_column_dollar_is_not_flagged() {
assert!(findings("\\begin{tabular}{>{$}c<{$}}\na & b\\end{tabular}\n").is_empty());
}
#[test]
fn definition_body_delimiter_is_not_flagged() {
assert!(findings("\\newcommand{\\x}{$}\n").is_empty());
assert!(findings("\\def\\x{$}\n").is_empty());
assert!(findings("\\newcommand{\\x}{\\[}\n").is_empty());
}
#[test]
fn expl3_region_delimiter_is_not_flagged() {
assert!(findings("\\ExplSyntaxOn\n\\[\n\\ExplSyntaxOff\n").is_empty());
}
#[test]
fn macrocode_body_delimiter_is_not_flagged() {
let out = findings_dtx("% \\begin{macrocode}\n\\[\n% \\end{macrocode}\n");
assert!(out.is_empty(), "got: {out:?}");
}
#[test]
fn bare_left_in_text_is_not_flagged() {
assert!(findings("plain \\left text\n").is_empty());
}
#[test]
fn carries_no_fix() {
let out = findings("Let $x = 1 be the base.\n");
assert!(out[0].fix.is_none());
}
}