forge_lint/sol/info/
mixed_case.rs

1use super::{MixedCaseFunction, MixedCaseVariable};
2use crate::{
3    linter::{EarlyLintPass, LintContext},
4    sol::{Severity, SolLint, info::screaming_snake_case::is_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            && !is_mixed_case(name.as_str(), true, ctx.config.mixed_case_exceptions)
19            && !is_constant_getter(&func.header)
20        {
21            ctx.emit(&MIXED_CASE_FUNCTION, name.span);
22        }
23    }
24}
25
26declare_forge_lint!(
27    MIXED_CASE_VARIABLE,
28    Severity::Info,
29    "mixed-case-variable",
30    "mutable variables should use mixedCase"
31);
32
33impl<'ast> EarlyLintPass<'ast> for MixedCaseVariable {
34    fn check_variable_definition(
35        &mut self,
36        ctx: &LintContext,
37        var: &'ast VariableDefinition<'ast>,
38    ) {
39        if var.mutability.is_none()
40            && let Some(name) = var.name
41            && !is_mixed_case(name.as_str(), false, ctx.config.mixed_case_exceptions)
42        {
43            ctx.emit(&MIXED_CASE_VARIABLE, name.span);
44        }
45    }
46}
47
48/// Checks if a string is mixedCase.
49///
50/// To avoid false positives like `fn increment()` or `uint256 counter`,
51/// lowercase strings are treated as mixedCase.
52pub fn is_mixed_case(s: &str, is_fn: bool, allowed_patterns: &[String]) -> bool {
53    if s.len() <= 1 {
54        return true;
55    }
56
57    // Remove leading/trailing underscores like `heck` does.
58    if check_lower_mixed_case(s.trim_matches('_')) {
59        return true;
60    }
61
62    // Ignore user-defined infixes.
63    for pattern in allowed_patterns {
64        if let Some(pos) = s.find(pattern.as_str())
65            && check_lower_mixed_case(&s[..pos])
66            && check_upper_mixed_case_post_pattern(&s[pos + pattern.len()..])
67        {
68            return true;
69        }
70    }
71
72    // Ignore `fn test*`, `fn invariant_*`, and `fn statefulFuzz*` patterns, as they usually contain
73    // (allowed) underscores.
74    is_fn && (s.starts_with("test") || s.starts_with("invariant_") || s.starts_with("statefulFuzz"))
75}
76
77fn check_lower_mixed_case(s: &str) -> bool {
78    s == heck::AsLowerCamelCase(s).to_string().as_str()
79}
80
81fn check_upper_mixed_case_post_pattern(s: &str) -> bool {
82    // Find the index of the first character that is not a numeric digit.
83    let Some(split_idx) = s.find(|c: char| !c.is_numeric()) else {
84        return true;
85    };
86
87    // Validate the characters preceding the initial numbers have the correct format.
88    let trimmed = &s[split_idx..];
89    if let Some(c) = trimmed.chars().next()
90        && !c.is_alphabetic()
91    {
92        return false;
93    }
94    trimmed == heck::AsUpperCamelCase(trimmed).to_string().as_str()
95}
96
97/// Checks if a function getter is a valid constant getter with a heuristic:
98///  * name is `SCREAMING_SNAKE_CASE`
99///  * external view visibility and mutability.
100///  * zero parameters.
101///  * exactly one return value.
102///  * return value is an elementary type
103fn is_constant_getter(header: &FunctionHeader<'_>) -> bool {
104    header.visibility().is_some_and(|v| matches!(v, Visibility::External))
105        && header.state_mutability().is_view()
106        && header.parameters.is_empty()
107        && header.returns().len() == 1
108        && header.returns().first().is_some_and(|ret| ret.ty.kind.is_elementary())
109        && is_screaming_snake_case(header.name.unwrap().as_str())
110}