forge_lint/sol/info/
mixed_case.rs

1use super::{MixedCaseFunction, MixedCaseVariable};
2use crate::{
3    linter::{EarlyLintPass, LintContext, Suggestion},
4    sol::{Severity, SolLint, info::screaming_snake_case::check_screaming_snake_case},
5};
6use solar::ast::{FunctionHeader, ItemFunction, VariableDefinition, Visibility};
7
8declare_forge_lint!(
9    MIXED_CASE_FUNCTION,
10    Severity::Info,
11    "mixed-case-function",
12    "function names should use mixedCase"
13);
14
15impl<'ast> EarlyLintPass<'ast> for MixedCaseFunction {
16    fn check_item_function(&mut self, ctx: &LintContext, func: &'ast ItemFunction<'ast>) {
17        if let Some(name) = func.header.name
18            && let Some(expected) =
19                check_mixed_case(name.as_str(), true, ctx.config.mixed_case_exceptions)
20            && !is_constant_getter(&func.header)
21        {
22            ctx.emit_with_suggestion(
23                &MIXED_CASE_FUNCTION,
24                name.span,
25                Suggestion::fix(
26                    expected,
27                    solar::interface::diagnostics::Applicability::MachineApplicable,
28                )
29                .with_desc("consider using"),
30            );
31        }
32    }
33}
34
35declare_forge_lint!(
36    MIXED_CASE_VARIABLE,
37    Severity::Info,
38    "mixed-case-variable",
39    "mutable variables should use mixedCase"
40);
41
42impl<'ast> EarlyLintPass<'ast> for MixedCaseVariable {
43    fn check_variable_definition(
44        &mut self,
45        ctx: &LintContext,
46        var: &'ast VariableDefinition<'ast>,
47    ) {
48        if var.mutability.is_none()
49            && let Some(name) = var.name
50            && let Some(expected) =
51                check_mixed_case(name.as_str(), false, ctx.config.mixed_case_exceptions)
52        {
53            ctx.emit_with_suggestion(
54                &MIXED_CASE_VARIABLE,
55                name.span,
56                Suggestion::fix(
57                    expected,
58                    solar::interface::diagnostics::Applicability::MachineApplicable,
59                )
60                .with_desc("consider using"),
61            );
62        }
63    }
64}
65
66/// If the string `s` is not mixedCase, returns a `Some(String)` with the
67/// suggested conversion. Otherwise, returns `None`.
68///
69/// To avoid false positives:
70/// - lowercase strings like `fn increment()` or `uint256 counter`, are treated as mixedCase.
71/// - test functions starting with `test`, `invariant_` or `statefulFuzz` are ignored.
72/// - user-defined patterns like `ERC20` are allowed.
73fn check_mixed_case(s: &str, is_fn: bool, allowed_patterns: &[String]) -> Option<String> {
74    if s.len() <= 1 {
75        return None;
76    }
77
78    // Exception for test, invariant, and stateful fuzzing functions.
79    if is_fn
80        && (s.starts_with("test") || s.starts_with("invariant_") || s.starts_with("statefulFuzz"))
81    {
82        return None;
83    }
84
85    // Exception for user-defined infix patterns.
86    for pattern in allowed_patterns {
87        if let Some(pos) = s.find(pattern.as_str()) {
88            let (pre, post) = s.split_at(pos);
89            let post = &post[pattern.len()..];
90
91            // Check if the part before the pattern is valid lowerCamelCase.
92            let is_pre_valid = pre == heck::AsLowerCamelCase(pre).to_string();
93
94            // Check if the part after is valid UpperCamelCase (allowing leading numbers).
95            let post_trimmed = post.trim_start_matches(|c: char| c.is_numeric());
96            let is_post_valid = post_trimmed == heck::AsUpperCamelCase(post_trimmed).to_string();
97
98            if is_pre_valid && is_post_valid {
99                return None;
100            }
101        }
102    }
103
104    // Generate the expected mixedCase version.
105    let suggestion = format!(
106        "{prefix}{name}{suffix}",
107        prefix = if s.starts_with('_') { "_" } else { "" },
108        name = heck::AsLowerCamelCase(s),
109        suffix = if s.ends_with('_') { "_" } else { "" }
110    );
111
112    // If the original string already matches the suggestion, it's valid.
113    if s == suggestion { None } else { Some(suggestion) }
114}
115
116/// Checks if a function getter is a valid constant getter with a heuristic:
117///  * name is `SCREAMING_SNAKE_CASE`
118///  * external view visibility and mutability.
119///  * zero parameters.
120///  * exactly one return value.
121///  * return value is an elementary or a custom type
122fn is_constant_getter(header: &FunctionHeader<'_>) -> bool {
123    header.visibility().is_some_and(|v| matches!(v, Visibility::External))
124        && header.state_mutability().is_view()
125        && header.parameters.is_empty()
126        && header.returns().len() == 1
127        && header
128            .returns()
129            .first()
130            .is_some_and(|ret| ret.ty.kind.is_elementary() || ret.ty.kind.is_custom())
131        && check_screaming_snake_case(header.name.unwrap().as_str()).is_none()
132}