Skip to main content

forge_lint/sol/gas/
var_read_using_this.rs

1use super::VarReadUsingThis;
2use crate::{
3    linter::{LateLintPass, LintContext, Suggestion},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast::{ContractKind, StateMutability},
8    interface::{Symbol, diagnostics::Applicability, sym},
9    sema::hir::{self, ExprKind, Res, StmtKind},
10};
11use std::collections::HashMap;
12
13declare_forge_lint!(
14    VAR_READ_USING_THIS,
15    Severity::Gas,
16    "var-read-using-this",
17    "reading a state variable via `this` causes an unnecessary STATICCALL; access it directly"
18);
19
20impl<'hir> LateLintPass<'hir> for VarReadUsingThis {
21    fn check_nested_contract(
22        &mut self,
23        ctx: &LintContext,
24        hir: &'hir hir::Hir<'hir>,
25        contract_id: hir::ContractId,
26    ) {
27        let contract = hir.contract(contract_id);
28
29        // `this` only exists inside contracts (concrete or abstract).
30        // Libraries have no `this`; interfaces have no function bodies.
31        if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) {
32            return;
33        }
34
35        // Collect every externally-callable function reachable on `this.X(...)`,
36        // grouped by name. Includes overloads (same name, different parameter types)
37        // as well as inherited overrides; `match_this_call` resolves them by arity
38        // and conservatively skips mixed-mutability overload sets.
39        let mut callable: HashMap<Symbol, Vec<&'hir hir::Function<'hir>>> = HashMap::new();
40        for &cid in contract.linearized_bases {
41            for fid in hir.contract(cid).functions() {
42                let func = hir.function(fid);
43                let Some(name) = func.name else { continue };
44                if !func.is_part_of_external_interface() {
45                    continue;
46                }
47                callable.entry(name.name).or_default().push(func);
48            }
49        }
50
51        if callable.is_empty() {
52            return;
53        }
54
55        // Walk state variable initializers (these run in the synthesized constructor).
56        for var_id in contract.variables() {
57            let var = hir.variable(var_id);
58            if let Some(init) = var.initializer {
59                walk_expr(ctx, init, &callable);
60            }
61        }
62
63        // Walk only function/modifier bodies *defined in this contract*; inherited
64        // bodies are walked when their defining contract is visited.
65        for fid in contract.all_functions() {
66            let func = hir.function(fid);
67
68            // Modifier invocations on the function (also covers base-class constructor
69            // calls, which solar stores in the same field for constructors).
70            for modifier in func.modifiers {
71                for arg in modifier.args.exprs() {
72                    walk_expr(ctx, arg, &callable);
73                }
74            }
75
76            if let Some(body) = func.body {
77                walk_block(ctx, hir, body, &callable);
78            }
79        }
80    }
81}
82
83fn walk_block<'hir>(
84    ctx: &LintContext,
85    hir: &'hir hir::Hir<'hir>,
86    block: hir::Block<'hir>,
87    callable: &HashMap<Symbol, Vec<&'hir hir::Function<'hir>>>,
88) {
89    for stmt in block.stmts {
90        walk_stmt(ctx, hir, stmt, callable);
91    }
92}
93
94fn walk_stmt<'hir>(
95    ctx: &LintContext,
96    hir: &'hir hir::Hir<'hir>,
97    stmt: &'hir hir::Stmt<'hir>,
98    callable: &HashMap<Symbol, Vec<&'hir hir::Function<'hir>>>,
99) {
100    match &stmt.kind {
101        StmtKind::Block(block) | StmtKind::UncheckedBlock(block) | StmtKind::Loop(block, _) => {
102            walk_block(ctx, hir, *block, callable);
103        }
104        StmtKind::If(cond, then_stmt, else_stmt) => {
105            walk_expr(ctx, cond, callable);
106            walk_stmt(ctx, hir, then_stmt, callable);
107            if let Some(else_stmt) = else_stmt {
108                walk_stmt(ctx, hir, else_stmt, callable);
109            }
110        }
111        StmtKind::Try(stmt_try) => {
112            // `try` requires an external call by Solidity's rules, so the outer
113            // `this.X(...)` cannot be replaced with a direct read. Skip flagging the
114            // top-level call but still recurse into its arguments so nested
115            // `this.X(...)` reads are caught.
116            let try_expr = &stmt_try.expr;
117            if let ExprKind::Call(callee, args, named_args) = &try_expr.kind {
118                walk_expr(ctx, callee, callable);
119                for e in args.exprs() {
120                    walk_expr(ctx, e, callable);
121                }
122                if let Some(nargs) = named_args {
123                    for arg in *nargs {
124                        walk_expr(ctx, &arg.value, callable);
125                    }
126                }
127            } else {
128                walk_expr(ctx, try_expr, callable);
129            }
130            for clause in stmt_try.clauses {
131                walk_block(ctx, hir, clause.block, callable);
132            }
133        }
134        StmtKind::DeclSingle(var_id) => {
135            if let Some(init) = hir.variable(*var_id).initializer {
136                walk_expr(ctx, init, callable);
137            }
138        }
139        StmtKind::DeclMulti(_, expr)
140        | StmtKind::Emit(expr)
141        | StmtKind::Revert(expr)
142        | StmtKind::Return(Some(expr))
143        | StmtKind::Expr(expr) => walk_expr(ctx, expr, callable),
144        StmtKind::Return(None)
145        | StmtKind::Break
146        | StmtKind::Continue
147        | StmtKind::Placeholder
148        | StmtKind::Err(_) => {}
149    }
150}
151
152fn walk_expr<'hir>(
153    ctx: &LintContext,
154    expr: &'hir hir::Expr<'hir>,
155    callable: &HashMap<Symbol, Vec<&'hir hir::Function<'hir>>>,
156) {
157    // Check the outer expression first so we report the entire `this.X(args)` call,
158    // then keep walking children to also find nested matches like `this.foo(this.bar)`.
159    if let Some(matched) = match_this_call(expr, callable) {
160        emit(ctx, expr, &matched);
161    }
162
163    match &expr.kind {
164        ExprKind::Array(exprs) => {
165            for e in *exprs {
166                walk_expr(ctx, e, callable);
167            }
168        }
169        ExprKind::Assign(lhs, _, rhs) | ExprKind::Binary(lhs, _, rhs) => {
170            walk_expr(ctx, lhs, callable);
171            walk_expr(ctx, rhs, callable);
172        }
173        ExprKind::Call(callee, args, named_args) => {
174            walk_expr(ctx, callee, callable);
175            for e in args.exprs() {
176                walk_expr(ctx, e, callable);
177            }
178            if let Some(nargs) = named_args {
179                for arg in *nargs {
180                    walk_expr(ctx, &arg.value, callable);
181                }
182            }
183        }
184        ExprKind::Delete(inner) | ExprKind::Payable(inner) | ExprKind::Unary(_, inner) => {
185            walk_expr(ctx, inner, callable);
186        }
187        ExprKind::Index(base, index) => {
188            walk_expr(ctx, base, callable);
189            if let Some(i) = index {
190                walk_expr(ctx, i, callable);
191            }
192        }
193        ExprKind::Slice(base, start, end) => {
194            walk_expr(ctx, base, callable);
195            if let Some(s) = start {
196                walk_expr(ctx, s, callable);
197            }
198            if let Some(e) = end {
199                walk_expr(ctx, e, callable);
200            }
201        }
202        ExprKind::Member(base, _) => walk_expr(ctx, base, callable),
203        ExprKind::Ternary(cond, then_e, else_e) => {
204            walk_expr(ctx, cond, callable);
205            walk_expr(ctx, then_e, callable);
206            walk_expr(ctx, else_e, callable);
207        }
208        ExprKind::Tuple(exprs) => {
209            for e in exprs.iter().flatten() {
210                walk_expr(ctx, e, callable);
211            }
212        }
213        ExprKind::Ident(_)
214        | ExprKind::Lit(_)
215        | ExprKind::New(_)
216        | ExprKind::TypeCall(_)
217        | ExprKind::Type(_)
218        | ExprKind::Err(_) => {}
219    }
220}
221
222#[derive(Clone, Copy)]
223struct MatchedCall<'hir> {
224    func: &'hir hir::Function<'hir>,
225    args: hir::CallArgs<'hir>,
226    /// Whether the call expression carries options like `{gas: ...}`.
227    has_call_options: bool,
228    /// The name on the right-hand side of the `this.<name>` member access.
229    member_name: Symbol,
230}
231
232/// Returns `Some(...)` if `expr` is a `this.<name>(args)` call where `<name>` resolves
233/// (via overload resolution by arity) to a `view`/`pure` external-interface function on
234/// the current contract.
235fn match_this_call<'hir>(
236    expr: &'hir hir::Expr<'hir>,
237    callable: &HashMap<Symbol, Vec<&'hir hir::Function<'hir>>>,
238) -> Option<MatchedCall<'hir>> {
239    let ExprKind::Call(callee, args, named_args) = &expr.kind else { return None };
240
241    // Allow `(this.foo)(args)` by peeling parens around the callee.
242    let ExprKind::Member(base, member) = &callee.peel_parens().kind else { return None };
243
244    // Allow `(this).foo(args)` by peeling parens around the base.
245    let ExprKind::Ident(resolutions) = &base.peel_parens().kind else { return None };
246    let is_this = resolutions.iter().any(|r| matches!(r, Res::Builtin(b) if b.name() == sym::this));
247    if !is_this {
248        return None;
249    }
250
251    let candidates = callable.get(&member.name)?;
252    let arity = args.len();
253
254    // Solar's HIR `Member(base, ident)` is name-based and does not carry the resolved
255    // function id, so we approximate overload resolution by arity. To avoid false
256    // positives when same-arity overloads mix mutability (e.g. `f(uint256) view` vs
257    // `f(address)` mutating), require ALL same-arity overloads to be `view`/`pure`.
258    let mut found: Option<&'hir hir::Function<'hir>> = None;
259    for f in candidates.iter().copied() {
260        if f.parameters.len() != arity {
261            continue;
262        }
263        if !matches!(f.state_mutability, StateMutability::View | StateMutability::Pure) {
264            return None;
265        }
266        if found.is_none() {
267            found = Some(f);
268        }
269    }
270    let func = found?;
271
272    Some(MatchedCall {
273        func,
274        args: *args,
275        has_call_options: named_args.is_some(),
276        member_name: member.name,
277    })
278}
279
280fn emit(ctx: &LintContext, expr: &hir::Expr<'_>, matched: &MatchedCall<'_>) {
281    // When the call carries options like `{gas: ...}`, the developer is intentionally
282    // reaching for the external-call machinery; flag the gas waste but do not auto-fix.
283    if matched.has_call_options {
284        ctx.emit(&VAR_READ_USING_THIS, expr.span);
285        return;
286    }
287
288    if let Some(suggestion) = build_suggestion(ctx, matched) {
289        ctx.emit_with_suggestion(&VAR_READ_USING_THIS, expr.span, suggestion);
290    } else {
291        ctx.emit(&VAR_READ_USING_THIS, expr.span);
292    }
293}
294
295fn build_suggestion(ctx: &LintContext, matched: &MatchedCall<'_>) -> Option<Suggestion> {
296    let name = matched.member_name.as_str();
297
298    if matched.func.is_getter() {
299        // Skip suggestions for struct-typed getters (multi-return) — the synthesized
300        // getter destructures struct fields, so a direct rewrite is not equivalent.
301        if matched.func.returns.len() != 1 {
302            return Some(
303                Suggestion::example(format!("read the state variable directly: `{name}`"))
304                    .with_desc("read the state variable directly instead of via `this.`"),
305            );
306        }
307
308        if matched.args.is_empty() {
309            // Simple state variable getter: `this.foo()` -> `foo`
310            return Some(
311                Suggestion::fix(name.to_string(), Applicability::MachineApplicable)
312                    .with_desc("consider reading the state variable directly"),
313            );
314        }
315
316        // Mapping/array getter: rebuild as `name[arg1][arg2]...` from arg snippets.
317        let mut indexed = String::from(name);
318        for arg in matched.args.exprs() {
319            let snippet = ctx.span_to_snippet(arg.span)?;
320            indexed.push('[');
321            indexed.push_str(snippet.trim());
322            indexed.push(']');
323        }
324        return Some(
325            Suggestion::fix(indexed, Applicability::MaybeIncorrect)
326                .with_desc("consider accessing storage directly"),
327        );
328    }
329
330    // Ordinary `view` / `pure` function: cannot auto-fix (visibility may be `external`,
331    // requiring a refactor to extract an internal helper). Show a generic example.
332    Some(
333        Suggestion::example(format!("call directly without `this.`: `{name}(...)`"))
334            .with_desc("avoid the STATICCALL by invoking the function directly"),
335    )
336}