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    /// Convert `input` to this convention. Used by `file_rename` to
63    /// derive a filename's corrected form. For `Lower`/`Upper` the
64    /// transform is a simple ASCII case flip on letters; other
65    /// conventions tokenize on separators and case transitions then
66    /// re-assemble.
67    pub fn convert(self, input: &str) -> String {
68        match self {
69            Self::Lower => input.to_ascii_lowercase(),
70            Self::Upper => input.to_ascii_uppercase(),
71            Self::Pascal => assemble_cap(&tokenize(input)),
72            Self::Camel => assemble_camel(&tokenize(input)),
73            Self::Snake => tokenize(input)
74                .iter()
75                .map(|t| t.to_ascii_lowercase())
76                .collect::<Vec<_>>()
77                .join("_"),
78            Self::Kebab => tokenize(input)
79                .iter()
80                .map(|t| t.to_ascii_lowercase())
81                .collect::<Vec<_>>()
82                .join("-"),
83            Self::ScreamingSnake => tokenize(input)
84                .iter()
85                .map(|t| t.to_ascii_uppercase())
86                .collect::<Vec<_>>()
87                .join("_"),
88            Self::Flat => tokenize(input)
89                .iter()
90                .map(|t| t.to_ascii_lowercase())
91                .collect(),
92        }
93    }
94}
95
96/// Split a case-mixed string into word tokens. Separators (`_`, `-`,
97/// space, `.`) split; case transitions split (`fooBar` → `foo`,`Bar`;
98/// `XMLParser` → `XML`,`Parser`). Tokens retain their original casing —
99/// conversion functions lowercase/titlecase as needed.
100fn tokenize(s: &str) -> Vec<String> {
101    let chars: Vec<char> = s.chars().collect();
102    let mut tokens: Vec<String> = Vec::new();
103    let mut current = String::new();
104    for (i, &c) in chars.iter().enumerate() {
105        if matches!(c, '_' | '-' | ' ' | '.') {
106            if !current.is_empty() {
107                tokens.push(std::mem::take(&mut current));
108            }
109            continue;
110        }
111        if c.is_ascii_uppercase() && !current.is_empty() {
112            let last = current.chars().last().unwrap();
113            let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_ascii_lowercase();
114            // Split before uppercase when prev is lowercase/digit
115            // (`fooBar` → `foo`,`Bar`) or when we're exiting an
116            // acronym run (`XMLParser` → `XML`,`Parser`).
117            if last.is_ascii_lowercase()
118                || last.is_ascii_digit()
119                || (last.is_ascii_uppercase() && next_is_lower)
120            {
121                tokens.push(std::mem::take(&mut current));
122            }
123        }
124        current.push(c);
125    }
126    if !current.is_empty() {
127        tokens.push(current);
128    }
129    tokens
130}
131
132fn title(s: &str) -> String {
133    let mut it = s.chars();
134    match it.next() {
135        None => String::new(),
136        Some(first) => {
137            let mut out = String::with_capacity(s.len());
138            out.push(first.to_ascii_uppercase());
139            for c in it {
140                out.push(c.to_ascii_lowercase());
141            }
142            out
143        }
144    }
145}
146
147fn assemble_cap(tokens: &[String]) -> String {
148    tokens.iter().map(|t| title(t)).collect()
149}
150
151fn assemble_camel(tokens: &[String]) -> String {
152    let mut it = tokens.iter();
153    let first = it
154        .next()
155        .map(|t| t.to_ascii_lowercase())
156        .unwrap_or_default();
157    std::iter::once(first).chain(it.map(|t| title(t))).collect()
158}
159
160/// Accept `PascalCase`, `pascal`, `pascal-case`, `pascalcase`, `pascal_case`
161/// as equivalent. Any separator character or case variation normalizes to a
162/// single canonical form before matching.
163impl<'de> Deserialize<'de> for CaseConvention {
164    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
165        let raw: String = String::deserialize(d)?;
166        let canon: String = raw
167            .chars()
168            .filter(char::is_ascii_alphabetic)
169            .map(|c| c.to_ascii_lowercase())
170            .collect();
171        match canon.as_str() {
172            "lower" | "lowercase" => Ok(Self::Lower),
173            "upper" | "uppercase" => Ok(Self::Upper),
174            "pascal" | "pascalcase" | "uppercamel" | "uppercamelcase" => Ok(Self::Pascal),
175            "camel" | "camelcase" | "lowercamel" | "lowercamelcase" => Ok(Self::Camel),
176            "snake" | "snakecase" => Ok(Self::Snake),
177            "kebab" | "kebabcase" | "dash" | "dashcase" => Ok(Self::Kebab),
178            "screamingsnake" | "screamingsnakecase" | "upper_snake" | "uppersnakecase" => {
179                Ok(Self::ScreamingSnake)
180            }
181            "flat" | "flatcase" => Ok(Self::Flat),
182            other => Err(serde::de::Error::custom(format!(
183                "unknown case convention {raw:?} (normalized to {other:?})",
184            ))),
185        }
186    }
187}
188
189fn is_lowercase(s: &str) -> bool {
190    s.chars().all(|c| !c.is_alphabetic() || c.is_lowercase())
191}
192
193fn is_uppercase(s: &str) -> bool {
194    s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase())
195}
196
197fn is_flat(s: &str) -> bool {
198    s.chars()
199        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
200}
201
202fn is_snake(s: &str) -> bool {
203    s.chars()
204        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
205}
206
207fn is_kebab(s: &str) -> bool {
208    s.chars()
209        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
210}
211
212fn is_screaming_snake(s: &str) -> bool {
213    s.chars()
214        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
215}
216
217fn is_camel(s: &str) -> bool {
218    check_camel_like(s, /* require_upper_first = */ false)
219}
220
221fn is_pascal(s: &str) -> bool {
222    check_camel_like(s, /* require_upper_first = */ true)
223}
224
225fn check_camel_like(s: &str, require_upper_first: bool) -> bool {
226    let mut chars = s.chars();
227    let Some(first) = chars.next() else {
228        return false;
229    };
230    if require_upper_first {
231        if !first.is_ascii_uppercase() {
232            return false;
233        }
234    } else if !first.is_ascii_lowercase() {
235        return false;
236    }
237    chars.all(|c| c.is_ascii_alphanumeric())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn pascal_accepts_simple() {
246        assert!(is_pascal("Button"));
247        assert!(is_pascal("FooBar"));
248        assert!(is_pascal("Foo1Bar"));
249        assert!(is_pascal("A"));
250        assert!(is_pascal("XMLParser")); // consecutive uppercase allowed
251    }
252
253    #[test]
254    fn pascal_rejects_wrong_shapes() {
255        assert!(!is_pascal(""));
256        assert!(!is_pascal("foo"));
257        assert!(!is_pascal("Foo_Bar"));
258        assert!(!is_pascal("Foo-Bar"));
259    }
260
261    #[test]
262    fn camel_accepts_simple() {
263        assert!(is_camel("fooBar"));
264        assert!(is_camel("foo"));
265        assert!(is_camel("ssrVFor"));
266        assert!(is_camel("getXMLParser")); // acronym run allowed
267        assert!(is_camel("foo1Bar"));
268    }
269
270    #[test]
271    fn camel_rejects_wrong_shapes() {
272        assert!(!is_camel("FooBar"));
273        assert!(!is_camel(""));
274        assert!(!is_camel("foo_bar"));
275    }
276
277    #[test]
278    fn snake_kebab() {
279        assert!(is_snake("foo_bar_baz"));
280        assert!(!is_snake("fooBar"));
281        assert!(is_kebab("foo-bar-baz"));
282        assert!(!is_kebab("foo_bar"));
283    }
284
285    #[test]
286    fn screaming_snake() {
287        assert!(is_screaming_snake("FOO_BAR"));
288        assert!(is_screaming_snake("HELLO_2_WORLD"));
289        assert!(!is_screaming_snake("Foo_Bar"));
290    }
291
292    #[test]
293    fn flat_vs_lower() {
294        assert!(is_flat("helloworld"));
295        assert!(!is_flat("hello_world"));
296        assert!(is_lowercase("hello_world")); // permissive: letters-only check
297    }
298
299    #[test]
300    fn convert_snake_from_various_shapes() {
301        assert_eq!(CaseConvention::Snake.convert("FooBar"), "foo_bar");
302        assert_eq!(CaseConvention::Snake.convert("fooBar"), "foo_bar");
303        assert_eq!(CaseConvention::Snake.convert("foo-bar"), "foo_bar");
304        assert_eq!(CaseConvention::Snake.convert("XMLParser"), "xml_parser");
305        assert_eq!(CaseConvention::Snake.convert("hello"), "hello");
306    }
307
308    #[test]
309    fn convert_pascal_from_various_shapes() {
310        assert_eq!(CaseConvention::Pascal.convert("foo_bar"), "FooBar");
311        assert_eq!(CaseConvention::Pascal.convert("foo-bar"), "FooBar");
312        assert_eq!(CaseConvention::Pascal.convert("fooBar"), "FooBar");
313        assert_eq!(CaseConvention::Pascal.convert("hello"), "Hello");
314    }
315
316    #[test]
317    fn convert_camel_kebab_screaming_flat() {
318        assert_eq!(CaseConvention::Camel.convert("FooBar"), "fooBar");
319        assert_eq!(CaseConvention::Kebab.convert("FooBar"), "foo-bar");
320        assert_eq!(CaseConvention::ScreamingSnake.convert("FooBar"), "FOO_BAR");
321        assert_eq!(CaseConvention::Flat.convert("FooBar"), "foobar");
322    }
323
324    #[test]
325    fn convert_is_idempotent_on_already_correct_input() {
326        assert_eq!(CaseConvention::Snake.convert("foo_bar"), "foo_bar");
327        assert_eq!(CaseConvention::Pascal.convert("FooBar"), "FooBar");
328        assert_eq!(CaseConvention::Kebab.convert("foo-bar"), "foo-bar");
329    }
330
331    #[test]
332    fn alias_deserialization() {
333        use serde_yaml_ng::from_str;
334        let cases = &[
335            ("PascalCase", CaseConvention::Pascal),
336            ("pascal", CaseConvention::Pascal),
337            ("pascal-case", CaseConvention::Pascal),
338            ("UpperCamelCase", CaseConvention::Pascal),
339            ("camelCase", CaseConvention::Camel),
340            ("camel", CaseConvention::Camel),
341            ("kebab-case", CaseConvention::Kebab),
342            ("KEBAB", CaseConvention::Kebab),
343            ("snake_case", CaseConvention::Snake),
344            ("SCREAMING_SNAKE_CASE", CaseConvention::ScreamingSnake),
345            ("flatcase", CaseConvention::Flat),
346        ];
347        for (input, expected) in cases {
348            let parsed: CaseConvention = from_str(&format!("\"{input}\"")).unwrap();
349            assert_eq!(parsed, *expected, "input = {input}");
350        }
351    }
352}