Skip to main content

rws_proxy/generator/
names.rs

1//! Names and field types: what `rws gen` accepts, and the spellings it
2//! derives (`BlogPost` → `blog_post` → `blog_posts`).
3
4use std::fmt;
5
6use super::GenError;
7
8/// The longest name `rws gen` accepts (a resource, model, migration or
9/// field).
10pub const MAX_NAME_LEN: usize = 64;
11
12/// Rust's keywords (2015–2024 editions, strict and reserved): never a
13/// module, field or function name.
14const KEYWORDS: [&str; 52] = [
15    "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop",
16    "match", "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
17    "unsafe", "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro", "override",
18    "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
19];
20
21/// A column type: one of the fixed list, optionally `Option<…>`.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum BaseType {
24    I64,
25    I32,
26    F64,
27    Bool,
28    String,
29}
30
31impl BaseType {
32    fn parse(text: &str) -> Option<BaseType> {
33        match text {
34            "i64" => Some(BaseType::I64),
35            "i32" => Some(BaseType::I32),
36            "f64" => Some(BaseType::F64),
37            "bool" => Some(BaseType::Bool),
38            "String" => Some(BaseType::String),
39            _ => None,
40        }
41    }
42
43    /// The Rust spelling.
44    pub fn rust(self) -> &'static str {
45        match self {
46            BaseType::I64 => "i64",
47            BaseType::I32 => "i32",
48            BaseType::F64 => "f64",
49            BaseType::Bool => "bool",
50            BaseType::String => "String",
51        }
52    }
53}
54
55/// A field's type: `i64`, `i32`, `f64`, `bool`, `String`, or `Option<T>` of
56/// one of those.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct FieldType {
59    pub base: BaseType,
60    pub optional: bool,
61}
62
63/// The types a field may have, as the error message lists them.
64pub const FIELD_TYPES: &str = "i64, i32, f64, bool, String, Option<T> (T one of those)";
65
66impl FieldType {
67    /// `"i64"`, `"Option<String>"`, … — anything else is an error naming
68    /// the list.
69    pub fn parse(text: &str) -> Result<FieldType, String> {
70        let text = text.trim();
71        if let Some(base) = BaseType::parse(text) {
72            return Ok(FieldType { base, optional: false });
73        }
74        let inner = text.strip_prefix("Option<").and_then(|rest| rest.strip_suffix('>')).map(str::trim);
75        match inner.and_then(BaseType::parse) {
76            Some(base) => Ok(FieldType { base, optional: true }),
77            None => Err(format!("unknown type `{text}`: use one of {FIELD_TYPES}")),
78        }
79    }
80
81    /// The Rust spelling (`Option<String>`).
82    pub fn rust(&self) -> String {
83        if self.optional {
84            format!("Option<{}>", self.base.rust())
85        } else {
86            self.base.rust().to_string()
87        }
88    }
89}
90
91impl fmt::Display for FieldType {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(&self.rust())
94    }
95}
96
97/// One `name:type` field of a model or a route's resource.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub struct Field {
100    /// The field (and column) name, snake_case.
101    pub name: String,
102    pub ty: FieldType,
103}
104
105impl Field {
106    /// `"title:String"`, `"note:Option<String>"`. The name must be a
107    /// snake_case identifier other than `id` (every model and resource
108    /// gets an `id`).
109    pub fn parse(spec: &str) -> Result<Field, GenError> {
110        let Some((name, ty)) = spec.split_once(':') else {
111            return Err(GenError::usage(format!("field `{spec}`: write name:type, e.g. title:String")));
112        };
113        let name = name.trim();
114        check_snake(name, "field")?;
115        if name == "id" {
116            return Err(GenError::usage("field `id`: every model and resource gets an `id: i64` already; leave it out"));
117        }
118        let ty = FieldType::parse(ty).map_err(|e| GenError::usage(format!("field `{name}`: {e}")))?;
119        Ok(Field { name: name.to_string(), ty })
120    }
121
122    /// The command-line spelling, `name:Type`.
123    pub fn spec(&self) -> String {
124        format!("{}:{}", self.name, self.ty)
125    }
126}
127
128/// Parse every `name:type`; names must not repeat.
129pub fn parse_fields<S: AsRef<str>>(specs: &[S]) -> Result<Vec<Field>, GenError> {
130    let mut fields: Vec<Field> = Vec::new();
131    for spec in specs {
132        let field = Field::parse(spec.as_ref())?;
133        if fields.iter().any(|f| f.name == field.name) {
134            return Err(GenError::usage(format!("field `{}` is given twice", field.name)));
135        }
136        fields.push(field);
137    }
138    Ok(fields)
139}
140
141/// An ASCII identifier: a letter, then letters, digits and `_`.
142fn is_identifier(name: &str) -> bool {
143    let mut chars = name.chars();
144    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
145}
146
147/// A snake_case name a module, file, table or field can have.
148pub fn check_snake(name: &str, what: &str) -> Result<(), GenError> {
149    if name.is_empty() {
150        return Err(GenError::usage(format!("the {what} name is missing")));
151    }
152    if name.len() > MAX_NAME_LEN {
153        let start: String = name.chars().take(MAX_NAME_LEN).collect();
154        return Err(GenError::usage(format!("{what} `{start}…` is longer than {MAX_NAME_LEN} characters")));
155    }
156    if !is_identifier(name) || name.chars().any(|c| c.is_ascii_uppercase()) || name.contains("__") || name.ends_with('_') {
157        return Err(GenError::usage(format!(
158            "{what} `{name}`: use a snake_case identifier (letters, digits and single `_`, starting with a letter) — it becomes a file name and a Rust identifier"
159        )));
160    }
161    if KEYWORDS.contains(&name) {
162        return Err(GenError::usage(format!("{what} `{name}` is a Rust keyword; pick another name")));
163    }
164    Ok(())
165}
166
167/// `BlogPost`, `blogPost` or `blog_post` → `blog_post`; then checked
168/// like [`check_snake`].
169pub fn to_snake(name: &str, what: &str) -> Result<String, GenError> {
170    let name = name.trim();
171    if name.is_empty() || name.len() > MAX_NAME_LEN || !is_identifier(name) {
172        check_snake(name, what)?;
173    }
174    let mut out = String::with_capacity(name.len() + 4);
175    let mut previous_lower = false;
176    for c in name.chars() {
177        if c.is_ascii_uppercase() {
178            if previous_lower {
179                out.push('_');
180            }
181            out.push(c.to_ascii_lowercase());
182            previous_lower = false;
183        } else {
184            out.push(c);
185            previous_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
186        }
187    }
188    check_snake(&out, what)?;
189    Ok(out)
190}
191
192/// `blog_post` → `BlogPost`.
193pub fn to_camel(snake: &str) -> String {
194    snake
195        .split('_')
196        .map(|part| {
197            let mut chars = part.chars();
198            match chars.next() {
199                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
200                None => String::new(),
201            }
202        })
203        .collect()
204}
205
206/// English plural of a snake_case noun, last word only (`blog_post` →
207/// `blog_posts`, `category` → `categories`, `box` → `boxes`).
208pub fn pluralize(snake: &str) -> String {
209    let consonant_y = snake.len() > 1
210        && snake.ends_with('y')
211        && !matches!(snake.as_bytes()[snake.len() - 2], b'a' | b'e' | b'i' | b'o' | b'u');
212    if consonant_y {
213        format!("{}ies", &snake[..snake.len() - 1])
214    } else if ["s", "x", "z", "ch", "sh"].iter().any(|end| snake.ends_with(end)) {
215        format!("{snake}es")
216    } else {
217        format!("{snake}s")
218    }
219}
220
221/// English singular of a snake_case plural (`categories` → `category`,
222/// `boxes` → `box`, `posts` → `post`); a word that doesn't look plural
223/// stays as it is.
224pub fn singularize(snake: &str) -> String {
225    if snake.len() > 3 && snake.ends_with("ies") {
226        return format!("{}y", &snake[..snake.len() - 3]);
227    }
228    for end in ["ses", "xes", "zes", "ches", "shes"] {
229        if snake.len() > end.len() && snake.ends_with(end) {
230            return snake[..snake.len() - 2].to_string();
231        }
232    }
233    if snake.len() > 1 && snake.ends_with('s') && !snake.ends_with("ss") && !snake.ends_with("us") {
234        return snake[..snake.len() - 1].to_string();
235    }
236    snake.to_string()
237}
238
239/// Type names the generated modules import or use; a model or resource
240/// type can't be one of them.
241pub const RESERVED_TYPES: [&str; 21] = [
242    "Self", "Option", "Result", "String", "Vec", "Box", "Json", "Path", "Value", "AppError", "Mutex", "MutexGuard", "BTreeMap",
243    "Serialize", "Deserialize", "StatusCodeReasonPhrase", "AppWithState", "AsyncAppWithState", "Model", "DbPool", "Repository",
244];
245
246/// Refuse a type name the generated code would clash with.
247pub fn check_type_name(camel: &str, what: &str) -> Result<(), GenError> {
248    if RESERVED_TYPES.contains(&camel) {
249        return Err(GenError::usage(format!(
250            "{what} type `{camel}` would clash with a name the generated code uses ({}); pick another name",
251            RESERVED_TYPES.join(", ")
252        )));
253    }
254    Ok(())
255}