forge_lint/sol/med/dangerous_unary_operator.rs
1use super::DangerousUnaryOperator;
2use crate::{
3 linter::{EarlyLintPass, LintContext},
4 sol::{Severity, SolLint},
5};
6use solar::ast::{Expr, ExprKind, UnOpKind};
7
8declare_forge_lint!(
9 DANGEROUS_UNARY_OPERATOR,
10 Severity::Med,
11 "dangerous-unary-operator",
12 "unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`"
13);
14
15impl<'ast> EarlyLintPass<'ast> for DangerousUnaryOperator {
16 fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) {
17 // `x =- 1` lexes as `x` `=` `-` `1` and parses as a plain assignment of `-1`, identical to
18 // the intentional `x = -1`, yet it reads like the compound `x -= 1` it was probably meant
19 // to be. Solidity has no `~=` operator either, so `x =~ y` is the same trap.
20 // The fused unary can also lead a larger RHS: `x =- a + 1` parses as `x = (-a) + 1`, whose
21 // RHS is a `Binary` (or `Ternary`) with the `-a` unary as its leftmost operand. Follow the
22 // left spine to that leading unary so those forms are caught too, not only `x =- a`.
23 // Because the parsed node matches the legitimate spaced form, only flag when the source
24 // fuses `=` to the leading unary (`=-` / `=~`), never `= -`. The gap between the LHS and
25 // `rhs.span` (which starts at that unary) holds only whitespace, comments and the `=`
26 // token, and no comment can end with `=` (block comments end with `*/`, line comments
27 // with a newline), so the gap ends with `=` exactly when the pair is fused. `=+` never
28 // reaches here: unary `+` was removed in Solidity 0.5.0, and solar drops it during
29 // parsing without producing a node.
30 if let ExprKind::Assign(lhs, None, rhs) = &expr.kind
31 && leads_with_fusable_unary(rhs)
32 && ctx.span_to_snippet(lhs.span.between(rhs.span)).is_some_and(|gap| gap.ends_with('='))
33 {
34 ctx.emit(&DANGEROUS_UNARY_OPERATOR, expr.span);
35 }
36 }
37}
38
39/// Whether the leftmost operand of `expr` is a `-` or `~` unary, following the left spine of
40/// binary and ternary expressions. `rhs.span` begins at that leading unary, so a fused `=-` / `=~`
41/// is caught whether the unary is the whole RHS (`x =- a`) or only leads it (`x =- a + 1`).
42fn leads_with_fusable_unary(expr: &Expr<'_>) -> bool {
43 match &expr.kind {
44 ExprKind::Unary(op, _) => matches!(op.kind, UnOpKind::Neg | UnOpKind::BitNot),
45 ExprKind::Binary(lhs, _, _) => leads_with_fusable_unary(lhs),
46 ExprKind::Ternary(cond, _, _) => leads_with_fusable_unary(cond),
47 _ => false,
48 }
49}