#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Style {
Lower,
Upper,
Pascal,
Camel,
Snake,
ScreamingSnake,
Kebab,
ScreamingKebab,
}
#[derive(Clone, Copy)]
enum Word {
Lower,
Upper,
Title,
}
impl Style {
const fn sep(self) -> Option<u8> {
match self {
Style::Snake | Style::ScreamingSnake => Some(b'_'),
Style::Kebab | Style::ScreamingKebab => Some(b'-'),
Style::Lower | Style::Upper | Style::Pascal | Style::Camel => None,
}
}
const fn first(self) -> Word {
match self {
Style::Camel => Word::Lower,
other => other.rest(),
}
}
const fn rest(self) -> Word {
match self {
Style::Lower | Style::Snake | Style::Kebab => Word::Lower,
Style::Upper | Style::ScreamingSnake | Style::ScreamingKebab => Word::Upper,
Style::Pascal | Style::Camel => Word::Title,
}
}
}
pub const fn style(rule: &str) -> Style {
if eq(rule, "lowercase") {
Style::Lower
} else if eq(rule, "UPPERCASE") {
Style::Upper
} else if eq(rule, "PascalCase") {
Style::Pascal
} else if eq(rule, "camelCase") {
Style::Camel
} else if eq(rule, "snake_case") {
Style::Snake
} else if eq(rule, "SCREAMING_SNAKE_CASE") {
Style::ScreamingSnake
} else if eq(rule, "kebab-case") {
Style::Kebab
} else if eq(rule, "SCREAMING-KEBAB-CASE") {
Style::ScreamingKebab
} else {
panic!(
"structio: unrecognized case rule. The rules are \"lowercase\", \"UPPERCASE\", \
\"PascalCase\", \"camelCase\", \"snake_case\", \"SCREAMING_SNAKE_CASE\", \
\"kebab-case\" and \"SCREAMING-KEBAB-CASE\", each written as a string"
)
}
}
const fn eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
const fn lower_like(b: u8) -> bool {
b.is_ascii_lowercase() || b >= 0x80
}
const fn starts_word(b: &[u8], i: usize) -> bool {
let mut j = i;
while j > 0 {
j -= 1;
if b[j] == b'_' {
continue;
}
if j + 1 != i {
return true;
}
if !b[i].is_ascii_uppercase() {
return false;
}
if lower_like(b[j]) || b[j].is_ascii_digit() {
return true;
}
if b[j].is_ascii_uppercase() {
return i + 1 < b.len() && lower_like(b[i + 1]);
}
return false;
}
true
}
const fn words(b: &[u8]) -> usize {
let mut i = 0;
let mut n = 0;
while i < b.len() {
if b[i] != b'_' && starts_word(b, i) {
n += 1;
}
i += 1;
}
n
}
pub const fn cased_len(name: &str, style: Style) -> usize {
let b = name.as_bytes();
let mut kept = 0;
let mut i = 0;
while i < b.len() {
if b[i] != b'_' {
kept += 1;
}
i += 1;
}
let words = words(b);
assert!(
words > 0,
"structio: a case rule was applied to a name that is nothing but underscores"
);
match style.sep() {
Some(_) => kept + words - 1,
None => kept,
}
}
const fn write_cased(name: &str, style: Style, out: &mut [u8]) -> usize {
let b = name.as_bytes();
let mut i = 0;
let mut o = 0;
let mut written = 0usize;
while i < b.len() {
if b[i] != b'_' {
let starts = starts_word(b, i);
if starts {
if let Some(sep) = style.sep()
&& written > 0
{
out[o] = sep;
o += 1;
}
written += 1;
}
let spelling = if written == 1 {
style.first()
} else {
style.rest()
};
out[o] = match spelling {
Word::Lower => b[i].to_ascii_lowercase(),
Word::Upper => b[i].to_ascii_uppercase(),
Word::Title => {
if starts {
b[i].to_ascii_uppercase()
} else {
b[i].to_ascii_lowercase()
}
}
};
o += 1;
}
i += 1;
}
o
}
pub const fn cased<const N: usize>(name: &str, style: Style) -> [u8; N] {
let mut out = [0u8; N];
let n = write_cased(name, style, &mut out);
assert!(
n == N,
"structio: `cased` was given a length that is not the name's `cased_len`"
);
out
}
pub const fn as_str(bytes: &[u8]) -> &str {
match core::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => panic!("structio: a converted key is not valid UTF-8"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn convert(name: &str, style: Style) -> String {
let mut buf = vec![0u8; 2 * name.len()];
let n = write_cased(name, style, &mut buf);
assert_eq!(n, cased_len(name, style), "{name:?} was mismeasured");
buf.truncate(n);
String::from_utf8(buf).expect("a converted name is still UTF-8")
}
#[test]
fn words_are_found_at_underscores_and_at_capitals() {
for (name, want) in [
("byte_offset", "byte-offset"),
("byteOffset", "byte-offset"),
("ByteOffset", "byte-offset"),
("HTTPUrl", "http-url"),
("HTTP", "http"),
("vec3_x", "vec3-x"),
("x2Y", "x2-y"),
("a_b_c", "a-b-c"),
("id", "id"),
("A", "a"),
] {
assert_eq!(convert(name, Style::Kebab), want, "{name}");
}
}
#[test]
fn separators_are_never_emitted() {
for (name, want) in [
("type_", "type"),
("_scratch", "scratch"),
("__a__b__", "a_b"),
] {
assert_eq!(convert(name, Style::Snake), want, "{name}");
}
}
#[test]
fn non_ascii_passes_through_and_ends_a_word() {
assert_eq!(
convert("caf\u{e9}_au_lait", Style::Camel),
"caf\u{e9}AuLait"
);
assert_eq!(convert("\u{3b1}\u{3b2}", Style::Pascal), "\u{3b1}\u{3b2}");
assert_eq!(convert("caf\u{e9}Bar", Style::Camel), "caf\u{e9}Bar");
assert_eq!(convert("caf\u{e9}Bar", Style::Kebab), "caf\u{e9}-bar");
assert_eq!(convert("HTTP\u{e9}lan", Style::Kebab), "htt-p\u{e9}lan");
}
#[test]
fn every_style_spells_one_name_its_own_way() {
let n = "http_byte_offset";
assert_eq!(convert(n, Style::Lower), "httpbyteoffset");
assert_eq!(convert(n, Style::Upper), "HTTPBYTEOFFSET");
assert_eq!(convert(n, Style::Pascal), "HttpByteOffset");
assert_eq!(convert(n, Style::Camel), "httpByteOffset");
assert_eq!(convert(n, Style::Snake), "http_byte_offset");
assert_eq!(convert(n, Style::ScreamingSnake), "HTTP_BYTE_OFFSET");
assert_eq!(convert(n, Style::Kebab), "http-byte-offset");
assert_eq!(convert(n, Style::ScreamingKebab), "HTTP-BYTE-OFFSET");
}
#[test]
fn every_spelling_names_a_style() {
for (rule, want) in [
("lowercase", Style::Lower),
("UPPERCASE", Style::Upper),
("PascalCase", Style::Pascal),
("camelCase", Style::Camel),
("snake_case", Style::Snake),
("SCREAMING_SNAKE_CASE", Style::ScreamingSnake),
("kebab-case", Style::Kebab),
("SCREAMING-KEBAB-CASE", Style::ScreamingKebab),
] {
assert_eq!(style(rule), want, "{rule}");
}
}
}