Skip to main content

typewriter_typescript/
mapper.rs

1//! TypeScript type mapper implementation.
2
3use typewriter_core::ir::*;
4use typewriter_core::mapper::TypeMapper;
5
6use crate::emitter;
7
8/// TypeScript language mapper.
9///
10/// Generates TypeScript interfaces from Rust structs and discriminated unions from enums.
11pub struct TypeScriptMapper {
12    /// Whether all fields should be `readonly`
13    pub readonly: bool,
14}
15
16impl TypeScriptMapper {
17    pub fn new() -> Self {
18        Self { readonly: false }
19    }
20
21    pub fn with_readonly(mut self, readonly: bool) -> Self {
22        self.readonly = readonly;
23        self
24    }
25}
26
27impl Default for TypeScriptMapper {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl TypeMapper for TypeScriptMapper {
34    fn map_primitive(&self, ty: &PrimitiveType) -> String {
35        match ty {
36            PrimitiveType::String => "string".to_string(),
37            PrimitiveType::Bool => "boolean".to_string(),
38            PrimitiveType::U8 | PrimitiveType::U16 | PrimitiveType::U32 => "number".to_string(),
39            PrimitiveType::I8 | PrimitiveType::I16 | PrimitiveType::I32 => "number".to_string(),
40            PrimitiveType::F32 | PrimitiveType::F64 => "number".to_string(),
41            PrimitiveType::U64 | PrimitiveType::U128 => "bigint".to_string(),
42            PrimitiveType::I64 | PrimitiveType::I128 => "bigint".to_string(),
43            PrimitiveType::Uuid => "string".to_string(),
44            PrimitiveType::DateTime | PrimitiveType::NaiveDate => "string".to_string(),
45            PrimitiveType::JsonValue => "unknown".to_string(),
46        }
47    }
48
49    fn map_option(&self, inner: &TypeKind) -> String {
50        format!("{} | undefined", self.map_type(inner))
51    }
52
53    fn map_vec(&self, inner: &TypeKind) -> String {
54        let inner_type = self.map_type(inner);
55        // Wrap union types in parens for array: (A | B)[]
56        if inner_type.contains('|') {
57            format!("({})[]", inner_type)
58        } else {
59            format!("{}[]", inner_type)
60        }
61    }
62
63    fn map_hashmap(&self, key: &TypeKind, value: &TypeKind) -> String {
64        format!("Record<{}, {}>", self.map_type(key), self.map_type(value))
65    }
66
67    fn map_tuple(&self, elements: &[TypeKind]) -> String {
68        let inner: Vec<String> = elements.iter().map(|e| self.map_type(e)).collect();
69        format!("[{}]", inner.join(", "))
70    }
71
72    fn map_named(&self, name: &str) -> String {
73        name.to_string()
74    }
75
76    fn emit_struct(&self, def: &StructDef) -> String {
77        emitter::render_interface(self, def)
78    }
79
80    fn emit_enum(&self, def: &EnumDef) -> String {
81        emitter::render_enum(self, def)
82    }
83
84    fn file_header(&self, type_name: &str) -> String {
85        format!(
86            "// Auto-generated by typewriter v0.1.0. DO NOT EDIT.\n\
87             // Source: {} \n\
88             // Regenerate: cargo typewriter generate\n\n",
89            type_name
90        )
91    }
92
93    fn file_extension(&self) -> &str {
94        "ts"
95    }
96
97    fn file_naming(&self, type_name: &str) -> String {
98        to_kebab_case(type_name)
99    }
100}
101
102/// Convert PascalCase to kebab-case.
103///
104/// # Examples
105/// - `"UserProfile"` → `"user-profile"`
106/// - `"HTTPResponse"` → `"http-response"`
107fn to_kebab_case(name: &str) -> String {
108    let mut result = String::new();
109    for (i, c) in name.chars().enumerate() {
110        if c.is_uppercase() {
111            if i > 0 {
112                // Check if previous char was lowercase or next char is lowercase (for acronyms)
113                let prev_lower = name.chars().nth(i - 1).map_or(false, |p| p.is_lowercase());
114                let next_lower = name.chars().nth(i + 1).map_or(false, |n| n.is_lowercase());
115                if prev_lower || next_lower {
116                    result.push('-');
117                }
118            }
119            result.push(c.to_lowercase().next().unwrap());
120        } else {
121            result.push(c);
122        }
123    }
124    result
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn mapper() -> TypeScriptMapper {
132        TypeScriptMapper::new()
133    }
134
135    #[test]
136    fn test_primitive_mappings() {
137        let m = mapper();
138        assert_eq!(m.map_primitive(&PrimitiveType::String), "string");
139        assert_eq!(m.map_primitive(&PrimitiveType::Bool), "boolean");
140        assert_eq!(m.map_primitive(&PrimitiveType::U32), "number");
141        assert_eq!(m.map_primitive(&PrimitiveType::I32), "number");
142        assert_eq!(m.map_primitive(&PrimitiveType::F64), "number");
143        assert_eq!(m.map_primitive(&PrimitiveType::U64), "bigint");
144        assert_eq!(m.map_primitive(&PrimitiveType::I64), "bigint");
145        assert_eq!(m.map_primitive(&PrimitiveType::Uuid), "string");
146        assert_eq!(m.map_primitive(&PrimitiveType::DateTime), "string");
147        assert_eq!(m.map_primitive(&PrimitiveType::JsonValue), "unknown");
148    }
149
150    #[test]
151    fn test_option_mapping() {
152        let m = mapper();
153        assert_eq!(
154            m.map_option(&TypeKind::Primitive(PrimitiveType::U32)),
155            "number | undefined"
156        );
157    }
158
159    #[test]
160    fn test_vec_mapping() {
161        let m = mapper();
162        assert_eq!(
163            m.map_vec(&TypeKind::Primitive(PrimitiveType::String)),
164            "string[]"
165        );
166    }
167
168    #[test]
169    fn test_hashmap_mapping() {
170        let m = mapper();
171        assert_eq!(
172            m.map_hashmap(
173                &TypeKind::Primitive(PrimitiveType::String),
174                &TypeKind::Primitive(PrimitiveType::U32)
175            ),
176            "Record<string, number>"
177        );
178    }
179
180    #[test]
181    fn test_tuple_mapping() {
182        let m = mapper();
183        assert_eq!(
184            m.map_tuple(&[
185                TypeKind::Primitive(PrimitiveType::String),
186                TypeKind::Primitive(PrimitiveType::U32)
187            ]),
188            "[string, number]"
189        );
190    }
191
192    #[test]
193    fn test_file_naming() {
194        let m = mapper();
195        assert_eq!(m.file_naming("UserProfile"), "user-profile");
196        assert_eq!(m.file_naming("User"), "user");
197        assert_eq!(m.file_naming("HTTPResponse"), "http-response");
198    }
199
200    #[test]
201    fn test_output_filename() {
202        let m = mapper();
203        assert_eq!(m.output_filename("UserProfile"), "user-profile.ts");
204    }
205
206    #[test]
207    fn test_emit_simple_struct() {
208        let m = mapper();
209        let def = StructDef {
210            name: "User".to_string(),
211            fields: vec![
212                FieldDef {
213                    name: "id".to_string(),
214                    ty: TypeKind::Primitive(PrimitiveType::Uuid),
215                    optional: false,
216                    rename: None,
217                    doc: None,
218                    skip: false,
219                    flatten: false,
220                },
221                FieldDef {
222                    name: "email".to_string(),
223                    ty: TypeKind::Primitive(PrimitiveType::String),
224                    optional: false,
225                    rename: None,
226                    doc: None,
227                    skip: false,
228                    flatten: false,
229                },
230                FieldDef {
231                    name: "age".to_string(),
232                    ty: TypeKind::Option(Box::new(TypeKind::Primitive(PrimitiveType::U32))),
233                    optional: true,
234                    rename: None,
235                    doc: None,
236                    skip: false,
237                    flatten: false,
238                },
239            ],
240            doc: None,
241            generics: vec![],
242        };
243
244        let output = m.emit_struct(&def);
245        assert!(output.contains("export interface User {"));
246        assert!(output.contains("id: string;"));
247        assert!(output.contains("email: string;"));
248        assert!(output.contains("age?: number | undefined;"));
249    }
250
251    #[test]
252    fn test_skipped_field() {
253        let m = mapper();
254        let def = StructDef {
255            name: "User".to_string(),
256            fields: vec![
257                FieldDef {
258                    name: "email".to_string(),
259                    ty: TypeKind::Primitive(PrimitiveType::String),
260                    optional: false,
261                    rename: None,
262                    doc: None,
263                    skip: false,
264                    flatten: false,
265                },
266                FieldDef {
267                    name: "password_hash".to_string(),
268                    ty: TypeKind::Primitive(PrimitiveType::String),
269                    optional: false,
270                    rename: None,
271                    doc: None,
272                    skip: true,
273                    flatten: false,
274                },
275            ],
276            doc: None,
277            generics: vec![],
278        };
279
280        let output = m.emit_struct(&def);
281        assert!(output.contains("email: string;"));
282        assert!(!output.contains("password_hash"));
283    }
284
285    #[test]
286    fn test_renamed_field() {
287        let m = mapper();
288        let def = StructDef {
289            name: "User".to_string(),
290            fields: vec![FieldDef {
291                name: "user_name".to_string(),
292                ty: TypeKind::Primitive(PrimitiveType::String),
293                optional: false,
294                rename: Some("userName".to_string()),
295                doc: None,
296                skip: false,
297                flatten: false,
298            }],
299            doc: None,
300            generics: vec![],
301        };
302
303        let output = m.emit_struct(&def);
304        assert!(output.contains("userName: string;"));
305    }
306
307    #[test]
308    fn test_simple_enum() {
309        let m = mapper();
310        let def = EnumDef {
311            name: "Role".to_string(),
312            variants: vec![
313                VariantDef {
314                    name: "Admin".to_string(),
315                    rename: None,
316                    kind: VariantKind::Unit,
317                    doc: None,
318                },
319                VariantDef {
320                    name: "User".to_string(),
321                    rename: None,
322                    kind: VariantKind::Unit,
323                    doc: None,
324                },
325                VariantDef {
326                    name: "Guest".to_string(),
327                    rename: None,
328                    kind: VariantKind::Unit,
329                    doc: None,
330                },
331            ],
332            representation: EnumRepr::External,
333            doc: None,
334        };
335
336        let output = m.emit_enum(&def);
337        assert!(output.contains("export type Role ="));
338        assert!(output.contains("\"Admin\""));
339        assert!(output.contains("\"User\""));
340        assert!(output.contains("\"Guest\""));
341    }
342}