Skip to main content

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