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