Skip to main content

alint_rules/
case.rs

1//! Case-convention detectors used by `filename_case` and `path_case`.
2//!
3//! Each detector checks whether a string already conforms to the named
4//! convention. Semantics follow ls-lint's well-established interpretations
5//! where they exist (see <https://ls-lint.org/2.3/configuration/rules.html>):
6//!
7//! - `lowercase` / `uppercase` — every *letter* is lower/upper; non-letters
8//!   are permitted (so `hello_world` is lowercase because every letter is).
9//! - `flat` — lowercase ASCII letters and digits only, no separators.
10//! - `snake`, `kebab`, `screaming-snake` — the obvious ASCII conventions.
11//! - `camel` — starts with an ASCII lowercase letter; all remaining
12//!   characters are ASCII alphanumeric. Consecutive uppercase letters are
13//!   permitted so common acronym styles like `ssrVFor` and `getXMLParser`
14//!   match. Use `filename_regex` if you need stricter semantics.
15//! - `pascal` — same rule as camel, but the first character is ASCII
16//!   uppercase.
17
18use serde::Deserialize;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CaseConvention {
22    Lower,
23    Upper,
24    Pascal,
25    Camel,
26    Snake,
27    Kebab,
28    ScreamingSnake,
29    Flat,
30}
31
32impl CaseConvention {
33    pub fn display_name(self) -> &'static str {
34        match self {
35            Self::Lower => "lowercase",
36            Self::Upper => "uppercase",
37            Self::Pascal => "PascalCase",
38            Self::Camel => "camelCase",
39            Self::Snake => "snake_case",
40            Self::Kebab => "kebab-case",
41            Self::ScreamingSnake => "SCREAMING_SNAKE_CASE",
42            Self::Flat => "flatcase",
43        }
44    }
45
46    pub fn check(self, s: &str) -> bool {
47        if s.is_empty() {
48            return false;
49        }
50        match self {
51            Self::Lower => is_lowercase(s),
52            Self::Upper => is_uppercase(s),
53            Self::Pascal => is_pascal(s),
54            Self::Camel => is_camel(s),
55            Self::Snake => is_snake(s),
56            Self::Kebab => is_kebab(s),
57            Self::ScreamingSnake => is_screaming_snake(s),
58            Self::Flat => is_flat(s),
59        }
60    }
61}
62
63/// Accept `PascalCase`, `pascal`, `pascal-case`, `pascalcase`, `pascal_case`
64/// as equivalent. Any separator character or case variation normalizes to a
65/// single canonical form before matching.
66impl<'de> Deserialize<'de> for CaseConvention {
67    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
68        let raw: String = String::deserialize(d)?;
69        let canon: String = raw
70            .chars()
71            .filter(char::is_ascii_alphabetic)
72            .map(|c| c.to_ascii_lowercase())
73            .collect();
74        match canon.as_str() {
75            "lower" | "lowercase" => Ok(Self::Lower),
76            "upper" | "uppercase" => Ok(Self::Upper),
77            "pascal" | "pascalcase" | "uppercamel" | "uppercamelcase" => Ok(Self::Pascal),
78            "camel" | "camelcase" | "lowercamel" | "lowercamelcase" => Ok(Self::Camel),
79            "snake" | "snakecase" => Ok(Self::Snake),
80            "kebab" | "kebabcase" | "dash" | "dashcase" => Ok(Self::Kebab),
81            "screamingsnake" | "screamingsnakecase" | "upper_snake" | "uppersnakecase" => {
82                Ok(Self::ScreamingSnake)
83            }
84            "flat" | "flatcase" => Ok(Self::Flat),
85            other => Err(serde::de::Error::custom(format!(
86                "unknown case convention {raw:?} (normalized to {other:?})",
87            ))),
88        }
89    }
90}
91
92fn is_lowercase(s: &str) -> bool {
93    s.chars().all(|c| !c.is_alphabetic() || c.is_lowercase())
94}
95
96fn is_uppercase(s: &str) -> bool {
97    s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase())
98}
99
100fn is_flat(s: &str) -> bool {
101    s.chars()
102        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
103}
104
105fn is_snake(s: &str) -> bool {
106    s.chars()
107        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
108}
109
110fn is_kebab(s: &str) -> bool {
111    s.chars()
112        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
113}
114
115fn is_screaming_snake(s: &str) -> bool {
116    s.chars()
117        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
118}
119
120fn is_camel(s: &str) -> bool {
121    check_camel_like(s, /* require_upper_first = */ false)
122}
123
124fn is_pascal(s: &str) -> bool {
125    check_camel_like(s, /* require_upper_first = */ true)
126}
127
128fn check_camel_like(s: &str, require_upper_first: bool) -> bool {
129    let mut chars = s.chars();
130    let Some(first) = chars.next() else {
131        return false;
132    };
133    if require_upper_first {
134        if !first.is_ascii_uppercase() {
135            return false;
136        }
137    } else if !first.is_ascii_lowercase() {
138        return false;
139    }
140    chars.all(|c| c.is_ascii_alphanumeric())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn pascal_accepts_simple() {
149        assert!(is_pascal("Button"));
150        assert!(is_pascal("FooBar"));
151        assert!(is_pascal("Foo1Bar"));
152        assert!(is_pascal("A"));
153        assert!(is_pascal("XMLParser")); // consecutive uppercase allowed
154    }
155
156    #[test]
157    fn pascal_rejects_wrong_shapes() {
158        assert!(!is_pascal(""));
159        assert!(!is_pascal("foo"));
160        assert!(!is_pascal("Foo_Bar"));
161        assert!(!is_pascal("Foo-Bar"));
162    }
163
164    #[test]
165    fn camel_accepts_simple() {
166        assert!(is_camel("fooBar"));
167        assert!(is_camel("foo"));
168        assert!(is_camel("ssrVFor"));
169        assert!(is_camel("getXMLParser")); // acronym run allowed
170        assert!(is_camel("foo1Bar"));
171    }
172
173    #[test]
174    fn camel_rejects_wrong_shapes() {
175        assert!(!is_camel("FooBar"));
176        assert!(!is_camel(""));
177        assert!(!is_camel("foo_bar"));
178    }
179
180    #[test]
181    fn snake_kebab() {
182        assert!(is_snake("foo_bar_baz"));
183        assert!(!is_snake("fooBar"));
184        assert!(is_kebab("foo-bar-baz"));
185        assert!(!is_kebab("foo_bar"));
186    }
187
188    #[test]
189    fn screaming_snake() {
190        assert!(is_screaming_snake("FOO_BAR"));
191        assert!(is_screaming_snake("HELLO_2_WORLD"));
192        assert!(!is_screaming_snake("Foo_Bar"));
193    }
194
195    #[test]
196    fn flat_vs_lower() {
197        assert!(is_flat("helloworld"));
198        assert!(!is_flat("hello_world"));
199        assert!(is_lowercase("hello_world")); // permissive: letters-only check
200    }
201
202    #[test]
203    fn alias_deserialization() {
204        use serde_yaml_ng::from_str;
205        let cases = &[
206            ("PascalCase", CaseConvention::Pascal),
207            ("pascal", CaseConvention::Pascal),
208            ("pascal-case", CaseConvention::Pascal),
209            ("UpperCamelCase", CaseConvention::Pascal),
210            ("camelCase", CaseConvention::Camel),
211            ("camel", CaseConvention::Camel),
212            ("kebab-case", CaseConvention::Kebab),
213            ("KEBAB", CaseConvention::Kebab),
214            ("snake_case", CaseConvention::Snake),
215            ("SCREAMING_SNAKE_CASE", CaseConvention::ScreamingSnake),
216            ("flatcase", CaseConvention::Flat),
217        ];
218        for (input, expected) in cases {
219            let parsed: CaseConvention = from_str(&format!("\"{input}\"")).unwrap();
220            assert_eq!(parsed, *expected, "input = {input}");
221        }
222    }
223}