use crate::ast::{AstNode, AstToken, BinaryExpr, Expr, Operator, UnaryExpr};
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
pub struct ComparisonNegation;
impl Rule for ComparisonNegation {
fn id(&self) -> &'static str {
"comparison-negation"
}
fn description(&self) -> &'static str {
"Flag `!` applied to a parenthesized equality test, which Julia spells \
with a single operator: `!(a == b)` is `a != b`, `!(a === b)` is \
`a !== b`, and both read back the other way. The Unicode spellings \
`≠`, `≡`, and `≢` collapse the same way.\n\n\
The rewrite is exact rather than merely equivalent-in-practice: Base \
defines `!=(x, y) = !(x == y)` and `!==(x, y) = !(x === y)`, so the \
two spellings agree on every input by construction.\n\n\
Only the equality family is reported. The orderings are left alone \
because `<` and `>=` are independent methods rather than negations of \
each other, and they disagree on exactly the inputs a partial order \
is partial about: `!(NaN < 1)` is `true` while `NaN >= 1` is `false`. \
The broadcast forms `a .== b` and `.!x` are containers of values \
rather than tests, and a comparison chain (`a == b == c`) has no \
two-operand rewrite, so none of them is flagged.\n\n\
The rule reports a safe fix that reuses the comparison's own source \
text with the operator swapped, so spacing and any comment between \
the operands survive. The fix is withheld — the finding still stands \
— when a comment sits in the deleted `!(` or `)`, and when the \
negation sits somewhere a bare comparison would rebind, as in \
`x + !(a == b)`."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "Negating an equality test spells the inequality:",
source: "if !(status == :ok)\n retry()\nend\n",
},
Example {
caption: "The identity comparison negates the same way:",
source: "found = !(lookup(key) === nothing)\n",
},
]
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::UNARY_EXPR]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(unary) = el.as_node().cloned().and_then(UnaryExpr::cast) else {
return;
};
if unary.op().map(|op| op.syntax().kind()) != Some(SyntaxKind::BANG) {
return;
}
let Some(Expr::ParenExpr(paren)) = unary.operand() else {
return;
};
let Some(Expr::BinaryExpr(bin)) = paren.expr() else {
return;
};
let Some(op) = bin.op() else { return };
let Some(negated) = negate(&op) else { return };
let message = format!(
"write the comparison directly: `!(a {op} b)` is `a {negated} b`",
op = op.text()
);
let mut diag = Diagnostic::new(self.id(), unary.syntax().text_range(), message);
if let Some(fix) = direct_rewrite(&unary, &bin, &op, negated) {
diag.fixes.push(fix);
}
sink.push(diag);
}
}
fn negate(op: &Operator) -> Option<&'static str> {
Some(match op.syntax().kind() {
SyntaxKind::EQ_EQ => "!=",
SyntaxKind::NOT_EQ => "==",
SyntaxKind::EQ_EQ_EQ => "!==",
SyntaxKind::NOT_EQ_EQ => "===",
SyntaxKind::UNICODE_OP => match op.text() {
"≠" => "==",
"≡" => "≢",
"≢" => "≡",
_ => return None,
},
_ => return None,
})
}
fn direct_rewrite(
unary: &UnaryExpr,
bin: &BinaryExpr,
op: &Operator,
negated: &str,
) -> Option<Fix> {
if !splices_without_rebinding(unary) || drops_a_comment(unary, bin) {
return None;
}
let text = bin.syntax().text().to_string();
let base = bin.syntax().text_range().start();
let head = usize::from(op.syntax().text_range().start() - base);
let tail = usize::from(op.syntax().text_range().end() - base);
let range = unary.syntax().text_range();
Some(Fix {
description: format!("Rewrite as `{negated}`"),
content: format!("{}{negated}{}", &text[..head], &text[tail..]),
start: range.start().into(),
end: range.end().into(),
applicability: Applicability::Safe,
})
}
fn drops_a_comment(unary: &UnaryExpr, bin: &BinaryExpr) -> bool {
let outer = unary.syntax().text_range();
let inner = bin.syntax().text_range();
let text = unary.syntax().text().to_string();
let head = usize::from(inner.start() - outer.start());
let tail = usize::from(inner.end() - outer.start());
text[..head].contains('#') || text[tail..].contains('#')
}
fn splices_without_rebinding(unary: &UnaryExpr) -> bool {
let Some(parent) = unary.syntax().parent() else {
return false;
};
match parent.kind() {
SyntaxKind::ROOT
| SyntaxKind::BLOCK
| SyntaxKind::PAREN_EXPR
| SyntaxKind::PAREN_BLOCK
| SyntaxKind::COMPREHENSION_IF
| SyntaxKind::CONDITION
| SyntaxKind::ASSIGNMENT_EXPR
| SyntaxKind::KEYWORD_ARG
| SyntaxKind::ARROW_EXPR
| SyntaxKind::TERNARY_EXPR
| SyntaxKind::RETURN_EXPR => true,
SyntaxKind::BINARY_EXPR => matches!(
binary_op(&parent),
Some(SyntaxKind::AND_AND | SyntaxKind::OR_OR)
),
SyntaxKind::ARG => matches!(
parent.parent().map(|it| it.kind()),
Some(SyntaxKind::ARG_LIST | SyntaxKind::VECT_EXPR | SyntaxKind::TUPLE_EXPR)
),
SyntaxKind::MACRO_CALL => parent
.children()
.last()
.is_some_and(|last| last == *unary.syntax()),
_ => false,
}
}
fn binary_op(binary: &SyntaxNode) -> Option<SyntaxKind> {
BinaryExpr::cast(binary.clone())?
.op()
.map(|op| op.syntax().kind())
}