Skip to main content

rkyv_js_codegen/
casing.rs

1//! Identifier casing conversion for emitted field and variant names.
2//!
3//! Rust spells struct fields `snake_case`; JavaScript spells them `camelCase`.
4//! Because rkyv lays a struct out positionally, the keys of the emitted `r.struct({ ... })` are labels only;
5//! renaming them changes the shape of the decoded JavaScript object without touching a single wire byte.
6
7/// The identifier casing of emitted field and variant names.
8///
9/// Configured through [`set_field_casing`](crate::CodeGenerator::set_field_casing)
10/// and [`set_variant_casing`](crate::CodeGenerator::set_variant_casing).
11///
12/// ```
13/// use rkyv_js_codegen::Casing;
14///
15/// assert_eq!(Casing::Camel.apply("created_at"), "createdAt");
16/// assert_eq!(Casing::Camel.apply("HTTP_status"), "httpStatus");
17/// assert_eq!(Casing::Pascal.apply("created_at"), "CreatedAt");
18/// assert_eq!(Casing::Snake.apply("createdAt"), "created_at");
19/// assert_eq!(Casing::Preserve.apply("created_at"), "created_at");
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Casing {
23    /// Emit the Rust name verbatim.
24    #[default]
25    Preserve,
26    /// `created_at` -> `createdAt`.
27    Camel,
28    /// `created_at` -> `CreatedAt`.
29    Pascal,
30    /// `createdAt` -> `created_at`.
31    Snake,
32}
33
34impl Casing {
35    /// Convert an identifier to this casing.
36    ///
37    /// Leading underscores are preserved (`_private` -> `_private`),
38    /// and a name that carries no word characters at all (`_`, `__`) is returned unchanged.
39    pub fn apply(self, name: &str) -> String {
40        if self == Casing::Preserve {
41            return name.to_string();
42        }
43
44        let trimmed = name.trim_start_matches('_');
45        let prefix = &name[..name.len() - trimmed.len()];
46        let words = split_words(trimmed);
47        if words.is_empty() {
48            return name.to_string();
49        }
50
51        let mut out = String::with_capacity(name.len() + prefix.len());
52        out.push_str(prefix);
53        match self {
54            Casing::Preserve => unreachable!("handled above"),
55            Casing::Camel => {
56                for (index, word) in words.iter().enumerate() {
57                    if index == 0 {
58                        out.push_str(&word.to_lowercase());
59                    } else {
60                        push_capitalized(&mut out, word);
61                    }
62                }
63            }
64            Casing::Pascal => {
65                for word in &words {
66                    push_capitalized(&mut out, word);
67                }
68            }
69            Casing::Snake => {
70                for (index, word) in words.iter().enumerate() {
71                    if index > 0 {
72                        out.push('_');
73                    }
74                    out.push_str(&word.to_lowercase());
75                }
76            }
77        }
78        out
79    }
80}
81
82fn push_capitalized(out: &mut String, word: &str) {
83    let mut chars = word.chars();
84    let Some(first) = chars.next() else {
85        return;
86    };
87    out.extend(first.to_uppercase());
88    out.push_str(&chars.as_str().to_lowercase());
89}
90
91/// Split an identifier into words on underscores and case boundaries.
92///
93/// An uppercase run is kept together as one word (`HTTPStatus` -> `HTTP`, `Status`),
94/// so acronyms survive the round trip through any casing.
95fn split_words(name: &str) -> Vec<String> {
96    let chars: Vec<char> = name.chars().collect();
97    let mut words = Vec::new();
98    let mut current = String::new();
99
100    for (index, &ch) in chars.iter().enumerate() {
101        if ch == '_' {
102            if !current.is_empty() {
103                words.push(std::mem::take(&mut current));
104            }
105            continue;
106        }
107        if ch.is_uppercase() && !current.is_empty() {
108            let previous = chars[index - 1];
109            let next_is_lower = chars.get(index + 1).is_some_and(|next| next.is_lowercase());
110            // `fooBar` → boundary before `B`; `HTTPStatus` → boundary before
111            // `S` only because `t` follows it.
112            if !previous.is_uppercase() || next_is_lower {
113                words.push(std::mem::take(&mut current));
114            }
115        }
116        current.push(ch);
117    }
118    if !current.is_empty() {
119        words.push(current);
120    }
121    words
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn preserve_is_the_identity() {
130        for name in ["created_at", "createdAt", "HTTPStatus", "_private", "__"] {
131            assert_eq!(Casing::Preserve.apply(name), name);
132        }
133    }
134
135    #[test]
136    fn camel_case_conversion() {
137        assert_eq!(Casing::Camel.apply("created_at"), "createdAt");
138        assert_eq!(Casing::Camel.apply("id"), "id");
139        assert_eq!(Casing::Camel.apply("a_b_c"), "aBC");
140        assert_eq!(Casing::Camel.apply("already_Mixed"), "alreadyMixed");
141        assert_eq!(Casing::Camel.apply("createdAt"), "createdAt");
142        assert_eq!(Casing::Camel.apply("CreatedAt"), "createdAt");
143    }
144
145    #[test]
146    fn pascal_case_conversion() {
147        assert_eq!(Casing::Pascal.apply("created_at"), "CreatedAt");
148        assert_eq!(Casing::Pascal.apply("createdAt"), "CreatedAt");
149        assert_eq!(Casing::Pascal.apply("CreatedAt"), "CreatedAt");
150    }
151
152    #[test]
153    fn snake_case_conversion() {
154        assert_eq!(Casing::Snake.apply("createdAt"), "created_at");
155        assert_eq!(Casing::Snake.apply("CreatedAt"), "created_at");
156        assert_eq!(Casing::Snake.apply("created_at"), "created_at");
157    }
158
159    #[test]
160    fn acronyms_stay_whole() {
161        assert_eq!(Casing::Camel.apply("HTTP_status"), "httpStatus");
162        assert_eq!(Casing::Camel.apply("HTTPStatus"), "httpStatus");
163        assert_eq!(Casing::Camel.apply("user_ID"), "userId");
164        assert_eq!(Casing::Snake.apply("HTTPStatus"), "http_status");
165        assert_eq!(Casing::Pascal.apply("http_status"), "HttpStatus");
166    }
167
168    #[test]
169    fn digits_join_the_preceding_word() {
170        assert_eq!(Casing::Camel.apply("field_2"), "field2");
171        assert_eq!(Casing::Camel.apply("x2_y3"), "x2Y3");
172        assert_eq!(Casing::Snake.apply("field2"), "field2");
173    }
174
175    #[test]
176    fn leading_underscores_are_preserved() {
177        assert_eq!(Casing::Camel.apply("_private_field"), "_privateField");
178        assert_eq!(Casing::Snake.apply("__internalValue"), "__internal_value");
179        // No word characters at all: nothing sensible to convert.
180        assert_eq!(Casing::Camel.apply("_"), "_");
181        assert_eq!(Casing::Camel.apply("___"), "___");
182    }
183
184    #[test]
185    fn trailing_underscores_are_dropped() {
186        // A trailing `_` carries no word; keeping it would be the only way a
187        // conversion could emit two different identifiers for one word.
188        assert_eq!(Casing::Camel.apply("type_"), "type");
189        assert_eq!(Casing::Snake.apply("type_"), "type");
190    }
191}