Skip to main content

forge_lint/sol/codesize/
unwrapped_modifier_logic.rs

1use super::UnwrappedModifierLogic;
2use crate::{
3    linter::{LateLintPass, LintContext, Suggestion},
4    sol::{Severity, SolLint, low::incorrect_modifier},
5};
6use solar::{
7    ast,
8    sema::hir::{self, Res, Visit as _},
9};
10use std::ops::ControlFlow;
11
12declare_forge_lint!(
13    UNWRAPPED_MODIFIER_LOGIC,
14    Severity::CodeSize,
15    "unwrapped-modifier-logic",
16    "wrap modifier logic to reduce code size"
17);
18
19impl<'hir> LateLintPass<'hir> for UnwrappedModifierLogic {
20    fn check_function(
21        &mut self,
22        ctx: &LintContext,
23        _gcx: solar::sema::Gcx<'hir>,
24        hir: &'hir hir::Hir<'hir>,
25        func: &'hir hir::Function<'hir>,
26    ) {
27        // Only check modifiers with a body and a name
28        let body = match (func.kind, &func.body, func.name) {
29            (ast::FunctionKind::Modifier, Some(body), Some(_)) => body,
30            _ => return,
31        };
32
33        if incorrect_modifier::block_outcome(*body).can_skip_placeholder() {
34            return;
35        }
36
37        // Only handle modifiers with exactly one placeholder, *and* require it to be top-level.
38        // Counting recursively (rather than just top-level statements) ensures a placeholder nested
39        // inside an `if`/loop/etc. is never extracted into a helper function, which would produce
40        // an invalid, behavior-changing rewrite.
41        if count_placeholders(body.stmts) != 1 {
42            return;
43        }
44        let Some(idx) =
45            body.stmts.iter().position(|s| matches!(s.kind, hir::StmtKind::Placeholder))
46        else {
47            // The single placeholder is nested; splitting it out would be unsafe.
48            return;
49        };
50
51        // Split statements into before and after the placeholder `_`.
52        let stmts = body.stmts[..].as_ref();
53        let (before, after) = (&stmts[..idx], &stmts[idx + 1..]);
54
55        // Generate a fix suggestion if the modifier logic should be wrapped.
56        if let Some(suggestion) = self.get_snippet(ctx, hir, func, before, after) {
57            ctx.emit_with_suggestion(
58                &UNWRAPPED_MODIFIER_LOGIC,
59                func.span.to(func.body_span),
60                suggestion,
61            );
62        }
63    }
64}
65
66impl UnwrappedModifierLogic {
67    /// Returns `true` if an expr is not a built-in ('require' or 'assert') call or a lib function.
68    fn is_valid_expr(&self, hir: &hir::Hir<'_>, expr: &hir::Expr<'_>) -> bool {
69        if let hir::ExprKind::Call(func_expr, _, _) = &expr.kind {
70            if let hir::ExprKind::Ident(resolutions) = &func_expr.kind {
71                return !resolutions.iter().any(|r| matches!(r, Res::Builtin(_)));
72            }
73
74            if let hir::ExprKind::Member(base, _) = &func_expr.kind
75                && let hir::ExprKind::Ident(resolutions) = &base.kind
76            {
77                return resolutions.iter().any(|r| {
78                    matches!(r, Res::Item(hir::ItemId::Contract(id)) if hir.contract(*id).kind == ast::ContractKind::Library)
79                });
80            }
81        }
82
83        false
84    }
85
86    /// Checks if a block of statements is complex and should be wrapped in a helper function.
87    ///
88    /// This always is 'false' the modifier contains assembly. We assume that if devs know how to
89    /// use assembly, they will also know how to reduce the codesize of their contracts and they
90    /// have a good reason to use it on their modifiers.
91    ///
92    /// This is 'true' if the block contains:
93    /// 1. Any statement that is not a placeholder or a valid expression.
94    /// 2. More than one simple call expression.
95    fn stmts_require_wrapping(&self, hir: &hir::Hir<'_>, stmts: &[hir::Stmt<'_>]) -> bool {
96        let (mut res, mut has_valid_stmt) = (false, false);
97        for stmt in stmts {
98            match &stmt.kind {
99                hir::StmtKind::Placeholder => {}
100                hir::StmtKind::Expr(expr) => {
101                    if !self.is_valid_expr(hir, expr) || has_valid_stmt {
102                        res = true;
103                    }
104                    has_valid_stmt = true;
105                }
106                // Assembly may contain control flow or side effects this lint does not model.
107                hir::StmtKind::AssemblyBlock(_)
108                | hir::StmtKind::Switch(_)
109                | hir::StmtKind::Err(_) => return false,
110                _ => res = true,
111            }
112        }
113
114        res
115    }
116
117    fn get_snippet<'hir>(
118        &self,
119        ctx: &LintContext,
120        hir: &'hir hir::Hir<'hir>,
121        func: &'hir hir::Function<'hir>,
122        before: &'hir [hir::Stmt<'hir>],
123        after: &'hir [hir::Stmt<'hir>],
124    ) -> Option<Suggestion> {
125        let wrap_before = !before.is_empty() && self.stmts_require_wrapping(hir, before);
126        let wrap_after = !after.is_empty() && self.stmts_require_wrapping(hir, after);
127
128        if !(wrap_before || wrap_after) {
129            return None;
130        }
131
132        // A local variable declared before the placeholder and referenced after it makes any
133        // rewrite unsafe: extracted helpers only receive the modifier's parameters, so moving
134        // either side out of the modifier separates the declaration from its use.
135        if has_shared_locals(hir, before, after)
136            || (wrap_before && has_written_params_used_after(hir, func.parameters, before, after))
137        {
138            return None;
139        }
140
141        let binding = func.name.unwrap();
142        let modifier_name = binding.name.as_str();
143        let mut param_list = vec![];
144        let mut param_decls = vec![];
145
146        for var_id in func.parameters {
147            let var = hir.variable(*var_id);
148            let ty = ctx
149                .span_to_snippet(var.ty.span)
150                .unwrap_or_else(|| "/* unknown type */".to_string());
151
152            // solidity functions should always have named parameters
153            if let Some(ident) = var.name {
154                param_list.push(ident.to_string());
155                param_decls.push(format!("{ty} {}", ident.to_string()));
156            }
157        }
158
159        let param_list = param_list.join(", ");
160        let param_decls = param_decls.join(", ");
161
162        let body_indent = " ".repeat(ctx.get_span_indentation(
163            before.first().or(after.first()).map(|stmt| stmt.span).unwrap_or(func.span),
164        ));
165        // Statements on a side that doesn't require wrapping are preserved verbatim in the new
166        // modifier body, so the rewrite never drops them.
167        let mut body_lines = Vec::new();
168        if wrap_before {
169            let suffix = if wrap_after { "Before" } else { "" };
170            body_lines.push(format!("{body_indent}_{modifier_name}{suffix}({param_list});"));
171        } else {
172            for stmt in before {
173                body_lines.push(format!("{body_indent}{}", ctx.span_to_snippet(stmt.span)?));
174            }
175        }
176        body_lines.push(format!("{body_indent}_;"));
177        if wrap_after {
178            let suffix = if wrap_before { "After" } else { "" };
179            body_lines.push(format!("{body_indent}_{modifier_name}{suffix}({param_list});"));
180        } else {
181            for stmt in after {
182                body_lines.push(format!("{body_indent}{}", ctx.span_to_snippet(stmt.span)?));
183            }
184        }
185        let body = body_lines.join("\n");
186
187        let mod_indent = " ".repeat(ctx.get_span_indentation(func.span));
188        let mut replacement =
189            format!("modifier {modifier_name}({param_decls}) {{\n{body}\n{mod_indent}}}");
190
191        let build_func = |stmts: &[hir::Stmt<'_>], suffix: &str| {
192            let body_stmts = stmts
193                .iter()
194                .map(|s| ctx.span_to_snippet(s.span).map(|code| format!("\n{body_indent}{code}")))
195                .collect::<Option<String>>()?;
196            Some(format!(
197                "\n\n{mod_indent}function _{modifier_name}{suffix}({param_decls}) internal {{{body_stmts}\n{mod_indent}}}"
198            ))
199        };
200
201        if wrap_before {
202            replacement.push_str(&build_func(before, if wrap_after { "Before" } else { "" })?);
203        }
204        if wrap_after {
205            replacement.push_str(&build_func(after, if wrap_before { "After" } else { "" })?);
206        }
207
208        Some(
209            Suggestion::fix(
210                replacement,
211                ast::interface::diagnostics::Applicability::MachineApplicable,
212            )
213            .with_desc("wrap modifier logic to reduce code size"),
214        )
215    }
216}
217
218/// Visitor that breaks on the first reference to any of the tracked local variables.
219struct SharedLocalFinder<'a, 'hir> {
220    hir: &'hir hir::Hir<'hir>,
221    locals: &'a [hir::VariableId],
222}
223
224impl<'hir> hir::Visit<'hir> for SharedLocalFinder<'_, 'hir> {
225    type BreakValue = ();
226
227    fn hir(&self) -> &'hir hir::Hir<'hir> {
228        self.hir
229    }
230
231    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
232        if let hir::ExprKind::Ident(resolutions) = &expr.kind
233            && resolutions.iter().any(
234                |r| matches!(r, Res::Item(hir::ItemId::Variable(id)) if self.locals.contains(id)),
235            )
236        {
237            return ControlFlow::Break(());
238        }
239        self.walk_expr(expr)
240    }
241}
242
243/// Returns `true` if a local variable declared in the `before` segment is referenced in the
244/// `after` segment.
245///
246/// Only top-level declarations need to be tracked: declarations nested inside blocks, loops, or
247/// `try` clauses are scoped to them and cannot be referenced after the placeholder.
248fn has_shared_locals<'hir>(
249    hir: &'hir hir::Hir<'hir>,
250    before: &'hir [hir::Stmt<'hir>],
251    after: &'hir [hir::Stmt<'hir>],
252) -> bool {
253    let mut declared = Vec::new();
254    for stmt in before {
255        match &stmt.kind {
256            hir::StmtKind::DeclSingle(id) => declared.push(*id),
257            hir::StmtKind::DeclMulti(ids, _) => declared.extend(ids.iter().copied().flatten()),
258            _ => {}
259        }
260    }
261    if declared.is_empty() {
262        return false;
263    }
264
265    let mut finder = SharedLocalFinder { hir, locals: &declared };
266    after.iter().any(|stmt| finder.visit_stmt(stmt).is_break())
267}
268
269/// Returns `true` if a modifier parameter is written in the `before` segment and referenced in
270/// the `after` segment.
271fn has_written_params_used_after<'hir>(
272    hir: &'hir hir::Hir<'hir>,
273    params: &'hir [hir::VariableId],
274    before: &'hir [hir::Stmt<'hir>],
275    after: &'hir [hir::Stmt<'hir>],
276) -> bool {
277    let mut written = Vec::new();
278    let mut finder = ParamWriteFinder { hir, params, written: &mut written };
279    for stmt in before {
280        let _ = finder.visit_stmt(stmt);
281    }
282
283    if written.is_empty() {
284        return false;
285    }
286
287    let mut finder = SharedLocalFinder { hir, locals: &written };
288    after.iter().any(|stmt| finder.visit_stmt(stmt).is_break())
289}
290
291/// Visitor that collects modifier parameters written by an expression.
292struct ParamWriteFinder<'a, 'hir> {
293    hir: &'hir hir::Hir<'hir>,
294    params: &'a [hir::VariableId],
295    written: &'a mut Vec<hir::VariableId>,
296}
297
298impl<'hir> hir::Visit<'hir> for ParamWriteFinder<'_, 'hir> {
299    type BreakValue = ();
300
301    fn hir(&self) -> &'hir hir::Hir<'hir> {
302        self.hir
303    }
304
305    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
306        match &expr.kind {
307            hir::ExprKind::Assign(lhs, _, _) | hir::ExprKind::Delete(lhs) => {
308                collect_written_params(lhs, self.params, self.written);
309            }
310            hir::ExprKind::Unary(op, inner)
311                if matches!(
312                    op.kind,
313                    ast::UnOpKind::PreInc
314                        | ast::UnOpKind::PreDec
315                        | ast::UnOpKind::PostInc
316                        | ast::UnOpKind::PostDec
317                ) =>
318            {
319                collect_written_params(inner, self.params, self.written);
320            }
321            _ => {}
322        }
323
324        self.walk_expr(expr)
325    }
326}
327
328fn collect_written_params(
329    expr: &hir::Expr<'_>,
330    params: &[hir::VariableId],
331    written: &mut Vec<hir::VariableId>,
332) {
333    match &expr.kind {
334        hir::ExprKind::Ident(resolutions) => {
335            for resolution in *resolutions {
336                if let Res::Item(hir::ItemId::Variable(id)) = resolution
337                    && params.contains(id)
338                    && !written.contains(id)
339                {
340                    written.push(*id);
341                }
342            }
343        }
344        hir::ExprKind::Tuple(items) => {
345            for item in items.iter().flatten() {
346                collect_written_params(item, params, written);
347            }
348        }
349        hir::ExprKind::Index(base, _)
350        | hir::ExprKind::Slice(base, _, _)
351        | hir::ExprKind::Member(base, _)
352        | hir::ExprKind::YulMember(base, _) => collect_written_params(base, params, written),
353        _ => {}
354    }
355}
356
357/// Recursively counts placeholder (`_`) statements within a list of statements, descending into
358/// nested blocks, conditionals, loops, `try`/`catch`, and Yul `switch` cases.
359fn count_placeholders(stmts: &[hir::Stmt<'_>]) -> usize {
360    stmts.iter().map(count_placeholders_in_stmt).sum()
361}
362
363fn count_placeholders_in_stmt(stmt: &hir::Stmt<'_>) -> usize {
364    match &stmt.kind {
365        hir::StmtKind::Placeholder => 1,
366        hir::StmtKind::Block(block)
367        | hir::StmtKind::UncheckedBlock(block)
368        | hir::StmtKind::AssemblyBlock(block)
369        | hir::StmtKind::Loop(block, _) => count_placeholders(block.stmts),
370        hir::StmtKind::If(_, then_stmt, else_stmt) => {
371            count_placeholders_in_stmt(then_stmt)
372                + else_stmt.map_or(0, |s| count_placeholders_in_stmt(s))
373        }
374        hir::StmtKind::Try(try_stmt) => {
375            try_stmt.clauses.iter().map(|clause| count_placeholders(clause.block.stmts)).sum()
376        }
377        hir::StmtKind::Switch(switch) => {
378            switch.cases.iter().map(|case| count_placeholders(case.body.stmts)).sum()
379        }
380        _ => 0,
381    }
382}