use crate::ast::{AstNode, AstToken, BinaryExpr, Expr, TernaryExpr};
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
pub struct RedundantBoolean;
impl Rule for RedundantBoolean {
fn id(&self) -> &'static str {
"redundant-boolean"
}
fn description(&self) -> &'static str {
"Flag a test compared against a boolean literal, or a conditional whose \
two arms are the literals themselves. `x == true` and `x != false` are \
`x`; `x == false` and `x != true` are `!x`; `c ? true : false` is `c` \
and `c ? false : true` is `!c`. Because `==` and `!=` are symmetric, \
the mirrored spellings (`true == x`) collapse the same way.\n\n\
This is distinct from `constant-condition`, which owns the \
literal-*as*-test case (`if true`), where the branch is decided before \
the code runs.\n\n\
The two halves do not ship the same fix. The conditional rewrite is \
reported as a safe fix: `?:` requires a `Bool` test, so on every input \
that does not throw, `c ? true : false` hands back that very `Bool`. \
The comparison rewrite is reported as an unsafe fix, because `==` is \
not identity — it promotes across the numeric tower (`1 == true` is \
`true`), answers `missing` for `missing`, and is overloadable — so the \
two spellings agree only when the operand is already a `Bool`.\n\n\
The deliberate `===` / `!==`, the broadcast `.==` / `.!=`, and a \
comparison chain are left alone, as are a comparison of two boolean \
literals (no operand survives it) and a conditional whose arms agree \
(constant rather than redundant).\n\n\
The fix reuses the surviving operand's own source text, parenthesizing \
it when a bare `!` would rebind (`a + b == false` becomes \
`!(a + b)`). It is withheld — the finding still stands — when a \
comment sits in the replaced span outside that operand."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "A conditional over the two literals is its own test:",
source: "ready = queue_started(q) ? true : false\n",
},
Example {
caption: "Comparing a test to a boolean literal restates it:",
source: "if x.valid == false\n reject(x)\nend\n",
},
]
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::BINARY_EXPR, SyntaxKind::TERNARY_EXPR]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else { return };
let Some(found) = (match node.kind() {
SyntaxKind::BINARY_EXPR => comparison(node),
SyntaxKind::TERNARY_EXPR => conditional(node),
_ => None,
}) else {
return;
};
let rewrite = if found.negated {
negated_text(&found.kept)
} else {
found.kept.syntax().text().to_string()
};
let (message, description, applicability) = match found.form {
Form::Comparison { literal } => (
format!("comparing to `{literal}` is redundant: write `{rewrite}`"),
format!("Drop the comparison to `{literal}`"),
Applicability::Unsafe,
),
Form::Conditional => (
format!("this conditional just yields `{rewrite}`"),
if found.negated {
"Replace the conditional with the negation of its test"
} else {
"Replace the conditional with its test"
}
.to_string(),
Applicability::Safe,
),
};
let range = node.text_range();
let mut diag = Diagnostic::new(self.id(), range, message);
if !drops_a_comment(node, &found.kept) {
diag.fixes.push(Fix {
description,
content: rewrite,
start: range.start().into(),
end: range.end().into(),
applicability,
});
}
sink.push(diag);
}
}
struct Redundancy {
kept: Expr,
negated: bool,
form: Form,
}
enum Form {
Comparison { literal: &'static str },
Conditional,
}
fn comparison(node: &SyntaxNode) -> Option<Redundancy> {
let bin = BinaryExpr::cast(node.clone())?;
let op = bin.op()?;
let (lhs, rhs) = (bin.lhs()?, bin.rhs()?);
let (kept, literal) = match (bool_literal(&lhs), bool_literal(&rhs)) {
(Some(_), Some(_)) | (None, None) => return None,
(None, Some(literal)) => (lhs, literal),
(Some(literal), None) => (rhs, literal),
};
let negated = match (op.syntax().kind(), literal) {
(SyntaxKind::EQ_EQ, true) | (SyntaxKind::NOT_EQ, false) => false,
(SyntaxKind::EQ_EQ, false) | (SyntaxKind::NOT_EQ, true) => true,
_ => return None,
};
Some(Redundancy {
kept,
negated,
form: Form::Comparison {
literal: if literal { "true" } else { "false" },
},
})
}
fn conditional(node: &SyntaxNode) -> Option<Redundancy> {
let ternary = TernaryExpr::cast(node.clone())?;
let then_branch = bool_literal(&ternary.then_branch()?)?;
let else_branch = bool_literal(&ternary.else_branch()?)?;
if then_branch == else_branch {
return None;
}
Some(Redundancy {
kept: ternary.condition()?,
negated: !then_branch,
form: Form::Conditional,
})
}
fn bool_literal(expr: &Expr) -> Option<bool> {
let Expr::Literal(literal) = expr else {
return None;
};
Some(literal.bool_token()?.kind() == SyntaxKind::TRUE_KW)
}
fn negated_text(expr: &Expr) -> String {
let text = expr.syntax().text().to_string();
if binds_at_least_as_tight_as_not(expr) {
format!("!{text}")
} else {
format!("!({text})")
}
}
fn binds_at_least_as_tight_as_not(expr: &Expr) -> bool {
match expr {
Expr::Literal(_)
| Expr::StringLiteral(_)
| Expr::CmdLiteral(_)
| Expr::NonstandardIdentifier(_)
| Expr::Name(_)
| Expr::ParenExpr(_)
| Expr::TupleExpr(_)
| Expr::VectExpr(_)
| Expr::MatrixExpr(_)
| Expr::Comprehension(_)
| Expr::Braces(_)
| Expr::CurlyExpr(_)
| Expr::CallExpr(_)
| Expr::IndexExpr(_)
| Expr::DotCallExpr(_) => true,
Expr::BinaryExpr(bin) => bin
.op()
.is_some_and(|op| op.syntax().kind() == SyntaxKind::DOT),
_ => false,
}
}
fn drops_a_comment(whole: &SyntaxNode, kept: &Expr) -> bool {
let outer = whole.text_range();
let inner = kept.syntax().text_range();
let text = whole.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('#')
}