Skip to main content

ruprizzle_parser/
naming.rs

1//! The naming convention: how a declaration becomes a physical name.
2//!
3//! Applied during lowering, once, so no downstream crate ever re-derives a table
4//! or column name. `@map` / `@@map` override everything here.
5//!
6//! The pluraliser is deliberately dumb. English is not tractable, and a clever
7//! implementation would be wrong in unpredictable places; a documented, boring one
8//! is wrong in *predictable* places, and `@@map("...")` is the escape hatch.
9
10/// Nouns whose plural is not formed by any rule worth encoding.
11const IRREGULAR: &[(&str, &str)] = &[
12    ("person", "people"),
13    ("child", "children"),
14    ("man", "men"),
15    ("woman", "women"),
16    ("tooth", "teeth"),
17    ("foot", "feet"),
18    ("mouse", "mice"),
19    ("goose", "geese"),
20    ("datum", "data"),
21    ("medium", "media"),
22    ("analysis", "analyses"),
23    ("index", "indexes"),
24];
25
26/// Nouns that are already their own plural.
27const UNCOUNTABLE: &[&str] = &[
28    "equipment",
29    "information",
30    "series",
31    "species",
32    "news",
33    "data",
34    "metadata",
35    "staff",
36    "sheep",
37    "fish",
38    "money",
39    "audio",
40    "settings",
41];
42
43/// Converts `PascalCase` or `camelCase` to `snake_case`.
44///
45/// Runs of capitals are treated as one word, so `HTTPHeader` becomes
46/// `http_header` rather than `h_t_t_p_header`, and a capital after a digit starts
47/// a new word (`v2Endpoint` → `v2_endpoint`).
48#[must_use]
49pub fn snake_case(input: &str) -> String {
50    let chars: Vec<char> = input.chars().collect();
51    let mut out = String::with_capacity(input.len() + 4);
52
53    for (i, &c) in chars.iter().enumerate() {
54        if c == '_' || c == '-' || c == ' ' {
55            if !out.ends_with('_') && !out.is_empty() {
56                out.push('_');
57            }
58            continue;
59        }
60        if c.is_ascii_uppercase() && i > 0 {
61            let prev = chars[i - 1];
62            let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
63            // A boundary is either lower→upper (`createdAt`) or the last capital
64            // of a run that starts a new word (`HTTPHeader`).
65            let boundary = prev.is_ascii_lowercase()
66                || prev.is_ascii_digit()
67                || (prev.is_ascii_uppercase() && next_is_lower);
68            if boundary && !out.ends_with('_') && !out.is_empty() {
69                out.push('_');
70            }
71        }
72        out.extend(c.to_lowercase());
73    }
74    out
75}
76
77/// Pluralises a lowercase English noun.
78#[must_use]
79pub fn pluralize(word: &str) -> String {
80    if word.is_empty() {
81        return String::new();
82    }
83    let lower = word.to_lowercase();
84
85    if UNCOUNTABLE.contains(&lower.as_str()) {
86        return lower;
87    }
88    if let Some((_, plural)) = IRREGULAR.iter().find(|(s, _)| *s == lower) {
89        return (*plural).to_owned();
90    }
91    // Only the final word of a compound is inflected: `blog_post` → `blog_posts`.
92    if let Some((head, tail)) = lower.rsplit_once('_') {
93        return format!("{head}_{}", pluralize(tail));
94    }
95
96    let sibilant = ["s", "x", "z", "ch", "sh"]
97        .iter()
98        .any(|suffix| lower.ends_with(suffix));
99    let consonant_y = lower.ends_with('y')
100        && lower
101            .chars()
102            .rev()
103            .nth(1)
104            .is_some_and(|p| !"aeiou".contains(p));
105
106    if sibilant {
107        format!("{lower}es")
108    } else if consonant_y {
109        format!("{}ies", &lower[..lower.len() - 1])
110    } else {
111        format!("{lower}s")
112    }
113}
114
115/// The table name for a model with no `@@map`.
116#[must_use]
117pub fn table_name(model: &str) -> String {
118    pluralize(&snake_case(model))
119}
120
121/// The column name for a field with no `@map`.
122#[must_use]
123pub fn column_name(field: &str) -> String {
124    snake_case(field)
125}
126
127/// The database type name for an enum with no `@@map`.
128#[must_use]
129pub fn enum_type_name(name: &str) -> String {
130    snake_case(name)
131}
132
133/// The derived name of an index over `columns` on `table`.
134#[must_use]
135pub fn index_name(table: &str, columns: &[String]) -> String {
136    format!("{table}_{}_idx", columns.join("_"))
137}
138
139/// The derived name of a unique constraint over `columns` on `table`.
140#[must_use]
141pub fn unique_name(table: &str, columns: &[String]) -> String {
142    format!("{table}_{}_key", columns.join("_"))
143}
144
145/// The derived name of a foreign key constraint over `columns` on `table`.
146#[must_use]
147pub fn foreign_key_name(table: &str, columns: &[String]) -> String {
148    format!("{table}_{}_fkey", columns.join("_"))
149}
150
151/// Rust keywords that cannot appear bare in generated code (V17).
152///
153/// Includes the reserved-but-unused set: a field named `become` compiles today
154/// but would break the day the keyword is activated, and the `r#` escape costs
155/// nothing.
156const RUST_KEYWORDS: &[&str] = &[
157    "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn",
158    "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
159    "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
160    "use", "where", "while", "async", "await", "box", "become", "do", "final", "macro", "override",
161    "priv", "try", "typeof", "unsized", "virtual", "yield",
162];
163
164/// Whether a field name needs `r#` escaping in generated Rust.
165#[must_use]
166pub fn is_rust_keyword(name: &str) -> bool {
167    RUST_KEYWORDS.contains(&name)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn snake_case_handles_the_shapes_we_actually_see() {
176        assert_eq!(snake_case("User"), "user");
177        assert_eq!(snake_case("createdAt"), "created_at");
178        assert_eq!(snake_case("BlogPost"), "blog_post");
179        assert_eq!(snake_case("already_snake"), "already_snake");
180        assert_eq!(snake_case("HTTPHeader"), "http_header");
181        assert_eq!(snake_case("APIKey"), "api_key");
182        assert_eq!(snake_case("v2Endpoint"), "v2_endpoint");
183        assert_eq!(snake_case("id"), "id");
184    }
185
186    #[test]
187    fn pluralize_follows_the_documented_rules() {
188        assert_eq!(pluralize("user"), "users");
189        assert_eq!(pluralize("post"), "posts");
190        assert_eq!(pluralize("category"), "categories");
191        assert_eq!(pluralize("address"), "addresses");
192        assert_eq!(pluralize("box"), "boxes");
193        assert_eq!(pluralize("dish"), "dishes");
194        assert_eq!(pluralize("day"), "days");
195        assert_eq!(pluralize("person"), "people");
196        assert_eq!(pluralize("series"), "series");
197        assert_eq!(pluralize("blog_post"), "blog_posts");
198    }
199
200    #[test]
201    fn table_and_column_names() {
202        assert_eq!(table_name("User"), "users");
203        assert_eq!(table_name("BlogPost"), "blog_posts");
204        assert_eq!(table_name("Category"), "categories");
205        assert_eq!(column_name("createdAt"), "created_at");
206        assert_eq!(enum_type_name("Role"), "role");
207    }
208
209    #[test]
210    fn keyword_detection_covers_reserved_words() {
211        assert!(is_rust_keyword("type"));
212        assert!(is_rust_keyword("become"));
213        assert!(!is_rust_keyword("email"));
214    }
215}