use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaseConvention {
Lower,
Upper,
Pascal,
Camel,
Snake,
Kebab,
ScreamingSnake,
Flat,
}
impl CaseConvention {
pub fn display_name(self) -> &'static str {
match self {
Self::Lower => "lowercase",
Self::Upper => "uppercase",
Self::Pascal => "PascalCase",
Self::Camel => "camelCase",
Self::Snake => "snake_case",
Self::Kebab => "kebab-case",
Self::ScreamingSnake => "SCREAMING_SNAKE_CASE",
Self::Flat => "flatcase",
}
}
pub fn check(self, s: &str) -> bool {
if s.is_empty() {
return false;
}
match self {
Self::Lower => is_lowercase(s),
Self::Upper => is_uppercase(s),
Self::Pascal => is_pascal(s),
Self::Camel => is_camel(s),
Self::Snake => is_snake(s),
Self::Kebab => is_kebab(s),
Self::ScreamingSnake => is_screaming_snake(s),
Self::Flat => is_flat(s),
}
}
pub fn convert(self, input: &str) -> String {
match self {
Self::Lower => input.to_ascii_lowercase(),
Self::Upper => input.to_ascii_uppercase(),
Self::Pascal => assemble_cap(&tokenize(input)),
Self::Camel => assemble_camel(&tokenize(input)),
Self::Snake => tokenize(input)
.iter()
.map(|t| t.to_ascii_lowercase())
.collect::<Vec<_>>()
.join("_"),
Self::Kebab => tokenize(input)
.iter()
.map(|t| t.to_ascii_lowercase())
.collect::<Vec<_>>()
.join("-"),
Self::ScreamingSnake => tokenize(input)
.iter()
.map(|t| t.to_ascii_uppercase())
.collect::<Vec<_>>()
.join("_"),
Self::Flat => tokenize(input)
.iter()
.map(|t| t.to_ascii_lowercase())
.collect(),
}
}
}
fn tokenize(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
for (i, &c) in chars.iter().enumerate() {
if matches!(c, '_' | '-' | ' ' | '.') {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
continue;
}
if c.is_ascii_uppercase() && !current.is_empty() {
let last = current.chars().last().unwrap();
let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_ascii_lowercase();
if last.is_ascii_lowercase()
|| last.is_ascii_digit()
|| (last.is_ascii_uppercase() && next_is_lower)
{
tokens.push(std::mem::take(&mut current));
}
}
current.push(c);
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn title(s: &str) -> String {
let mut it = s.chars();
match it.next() {
None => String::new(),
Some(first) => {
let mut out = String::with_capacity(s.len());
out.push(first.to_ascii_uppercase());
for c in it {
out.push(c.to_ascii_lowercase());
}
out
}
}
}
fn assemble_cap(tokens: &[String]) -> String {
tokens.iter().map(|t| title(t)).collect()
}
fn assemble_camel(tokens: &[String]) -> String {
let mut it = tokens.iter();
let first = it
.next()
.map(|t| t.to_ascii_lowercase())
.unwrap_or_default();
std::iter::once(first).chain(it.map(|t| title(t))).collect()
}
impl<'de> Deserialize<'de> for CaseConvention {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw: String = String::deserialize(d)?;
let canon: String = raw
.chars()
.filter(char::is_ascii_alphabetic)
.map(|c| c.to_ascii_lowercase())
.collect();
match canon.as_str() {
"lower" | "lowercase" => Ok(Self::Lower),
"upper" | "uppercase" => Ok(Self::Upper),
"pascal" | "pascalcase" | "uppercamel" | "uppercamelcase" => Ok(Self::Pascal),
"camel" | "camelcase" | "lowercamel" | "lowercamelcase" => Ok(Self::Camel),
"snake" | "snakecase" => Ok(Self::Snake),
"kebab" | "kebabcase" | "dash" | "dashcase" => Ok(Self::Kebab),
"screamingsnake" | "screamingsnakecase" | "upper_snake" | "uppersnakecase" => {
Ok(Self::ScreamingSnake)
}
"flat" | "flatcase" => Ok(Self::Flat),
other => Err(serde::de::Error::custom(format!(
"unknown case convention {raw:?} (normalized to {other:?})",
))),
}
}
}
fn is_lowercase(s: &str) -> bool {
s.chars().all(|c| !c.is_alphabetic() || c.is_lowercase())
}
fn is_uppercase(s: &str) -> bool {
s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase())
}
fn is_flat(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
fn is_snake(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn is_kebab(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_screaming_snake(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
fn is_camel(s: &str) -> bool {
check_camel_like(s, false)
}
fn is_pascal(s: &str) -> bool {
check_camel_like(s, true)
}
fn check_camel_like(s: &str, require_upper_first: bool) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if require_upper_first {
if !first.is_ascii_uppercase() {
return false;
}
} else if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pascal_accepts_simple() {
assert!(is_pascal("Button"));
assert!(is_pascal("FooBar"));
assert!(is_pascal("Foo1Bar"));
assert!(is_pascal("A"));
assert!(is_pascal("XMLParser")); }
#[test]
fn pascal_rejects_wrong_shapes() {
assert!(!is_pascal(""));
assert!(!is_pascal("foo"));
assert!(!is_pascal("Foo_Bar"));
assert!(!is_pascal("Foo-Bar"));
}
#[test]
fn camel_accepts_simple() {
assert!(is_camel("fooBar"));
assert!(is_camel("foo"));
assert!(is_camel("ssrVFor"));
assert!(is_camel("getXMLParser")); assert!(is_camel("foo1Bar"));
}
#[test]
fn camel_rejects_wrong_shapes() {
assert!(!is_camel("FooBar"));
assert!(!is_camel(""));
assert!(!is_camel("foo_bar"));
}
#[test]
fn snake_kebab() {
assert!(is_snake("foo_bar_baz"));
assert!(!is_snake("fooBar"));
assert!(is_kebab("foo-bar-baz"));
assert!(!is_kebab("foo_bar"));
}
#[test]
fn screaming_snake() {
assert!(is_screaming_snake("FOO_BAR"));
assert!(is_screaming_snake("HELLO_2_WORLD"));
assert!(!is_screaming_snake("Foo_Bar"));
}
#[test]
fn flat_vs_lower() {
assert!(is_flat("helloworld"));
assert!(!is_flat("hello_world"));
assert!(is_lowercase("hello_world")); }
#[test]
fn convert_snake_from_various_shapes() {
assert_eq!(CaseConvention::Snake.convert("FooBar"), "foo_bar");
assert_eq!(CaseConvention::Snake.convert("fooBar"), "foo_bar");
assert_eq!(CaseConvention::Snake.convert("foo-bar"), "foo_bar");
assert_eq!(CaseConvention::Snake.convert("XMLParser"), "xml_parser");
assert_eq!(CaseConvention::Snake.convert("hello"), "hello");
}
#[test]
fn convert_pascal_from_various_shapes() {
assert_eq!(CaseConvention::Pascal.convert("foo_bar"), "FooBar");
assert_eq!(CaseConvention::Pascal.convert("foo-bar"), "FooBar");
assert_eq!(CaseConvention::Pascal.convert("fooBar"), "FooBar");
assert_eq!(CaseConvention::Pascal.convert("hello"), "Hello");
}
#[test]
fn convert_camel_kebab_screaming_flat() {
assert_eq!(CaseConvention::Camel.convert("FooBar"), "fooBar");
assert_eq!(CaseConvention::Kebab.convert("FooBar"), "foo-bar");
assert_eq!(CaseConvention::ScreamingSnake.convert("FooBar"), "FOO_BAR");
assert_eq!(CaseConvention::Flat.convert("FooBar"), "foobar");
}
#[test]
fn convert_is_idempotent_on_already_correct_input() {
assert_eq!(CaseConvention::Snake.convert("foo_bar"), "foo_bar");
assert_eq!(CaseConvention::Pascal.convert("FooBar"), "FooBar");
assert_eq!(CaseConvention::Kebab.convert("foo-bar"), "foo-bar");
}
#[test]
fn alias_deserialization() {
use serde_yaml_ng::from_str;
let cases = &[
("PascalCase", CaseConvention::Pascal),
("pascal", CaseConvention::Pascal),
("pascal-case", CaseConvention::Pascal),
("UpperCamelCase", CaseConvention::Pascal),
("camelCase", CaseConvention::Camel),
("camel", CaseConvention::Camel),
("kebab-case", CaseConvention::Kebab),
("KEBAB", CaseConvention::Kebab),
("snake_case", CaseConvention::Snake),
("SCREAMING_SNAKE_CASE", CaseConvention::ScreamingSnake),
("flatcase", CaseConvention::Flat),
];
for (input, expected) in cases {
let parsed: CaseConvention = from_str(&format!("\"{input}\"")).unwrap();
assert_eq!(parsed, *expected, "input = {input}");
}
}
}