Skip to main content

typewriter_typescript/
mapper.rs

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