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.1.1. 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 file_naming(&self, type_name: &str) -> String {
109        to_file_style(type_name, self.file_style)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn mapper() -> TypeScriptMapper {
118        TypeScriptMapper::new()
119    }
120
121    #[test]
122    fn test_primitive_mappings() {
123        let m = mapper();
124        assert_eq!(m.map_primitive(&PrimitiveType::String), "string");
125        assert_eq!(m.map_primitive(&PrimitiveType::Bool), "boolean");
126        assert_eq!(m.map_primitive(&PrimitiveType::U32), "number");
127        assert_eq!(m.map_primitive(&PrimitiveType::I32), "number");
128        assert_eq!(m.map_primitive(&PrimitiveType::F64), "number");
129        assert_eq!(m.map_primitive(&PrimitiveType::U64), "bigint");
130        assert_eq!(m.map_primitive(&PrimitiveType::I64), "bigint");
131        assert_eq!(m.map_primitive(&PrimitiveType::Uuid), "string");
132        assert_eq!(m.map_primitive(&PrimitiveType::DateTime), "string");
133        assert_eq!(m.map_primitive(&PrimitiveType::JsonValue), "unknown");
134    }
135
136    #[test]
137    fn test_option_mapping() {
138        let m = mapper();
139        assert_eq!(
140            m.map_option(&TypeKind::Primitive(PrimitiveType::U32)),
141            "number | undefined"
142        );
143    }
144
145    #[test]
146    fn test_vec_mapping() {
147        let m = mapper();
148        assert_eq!(
149            m.map_vec(&TypeKind::Primitive(PrimitiveType::String)),
150            "string[]"
151        );
152    }
153
154    #[test]
155    fn test_hashmap_mapping() {
156        let m = mapper();
157        assert_eq!(
158            m.map_hashmap(
159                &TypeKind::Primitive(PrimitiveType::String),
160                &TypeKind::Primitive(PrimitiveType::U32)
161            ),
162            "Record<string, number>"
163        );
164    }
165
166    #[test]
167    fn test_tuple_mapping() {
168        let m = mapper();
169        assert_eq!(
170            m.map_tuple(&[
171                TypeKind::Primitive(PrimitiveType::String),
172                TypeKind::Primitive(PrimitiveType::U32)
173            ]),
174            "[string, number]"
175        );
176    }
177
178    #[test]
179    fn test_file_naming_kebab() {
180        let m = mapper();
181        assert_eq!(m.file_naming("UserProfile"), "user-profile");
182        assert_eq!(m.file_naming("User"), "user");
183        assert_eq!(m.file_naming("HTTPResponse"), "http-response");
184    }
185
186    #[test]
187    fn test_file_naming_snake() {
188        let m = TypeScriptMapper::new().with_file_style(FileStyle::SnakeCase);
189        assert_eq!(m.file_naming("UserProfile"), "user_profile");
190        assert_eq!(m.file_naming("HTTPResponse"), "http_response");
191    }
192
193    #[test]
194    fn test_file_naming_pascal() {
195        let m = TypeScriptMapper::new().with_file_style(FileStyle::PascalCase);
196        assert_eq!(m.file_naming("UserProfile"), "UserProfile");
197        assert_eq!(m.file_naming("HTTPResponse"), "HTTPResponse");
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_output_filename_pascal() {
208        let m = TypeScriptMapper::new().with_file_style(FileStyle::PascalCase);
209        assert_eq!(m.output_filename("UserProfile"), "UserProfile.ts");
210    }
211
212    #[test]
213    fn test_emit_simple_struct() {
214        let m = mapper();
215        let def = StructDef {
216            name: "User".to_string(),
217            fields: vec![
218                FieldDef {
219                    name: "id".to_string(),
220                    ty: TypeKind::Primitive(PrimitiveType::Uuid),
221                    optional: false,
222                    rename: None,
223                    doc: None,
224                    skip: false,
225                    flatten: false,
226                },
227                FieldDef {
228                    name: "email".to_string(),
229                    ty: TypeKind::Primitive(PrimitiveType::String),
230                    optional: false,
231                    rename: None,
232                    doc: None,
233                    skip: false,
234                    flatten: false,
235                },
236                FieldDef {
237                    name: "age".to_string(),
238                    ty: TypeKind::Option(Box::new(TypeKind::Primitive(PrimitiveType::U32))),
239                    optional: true,
240                    rename: None,
241                    doc: None,
242                    skip: false,
243                    flatten: false,
244                },
245            ],
246            doc: None,
247            generics: vec![],
248        };
249
250        let output = m.emit_struct(&def);
251        assert!(output.contains("export interface User {"));
252        assert!(output.contains("id: string;"));
253        assert!(output.contains("email: string;"));
254        assert!(output.contains("age?: number | undefined;"));
255    }
256
257    #[test]
258    fn test_skipped_field() {
259        let m = mapper();
260        let def = StructDef {
261            name: "User".to_string(),
262            fields: vec![
263                FieldDef {
264                    name: "email".to_string(),
265                    ty: TypeKind::Primitive(PrimitiveType::String),
266                    optional: false,
267                    rename: None,
268                    doc: None,
269                    skip: false,
270                    flatten: false,
271                },
272                FieldDef {
273                    name: "password_hash".to_string(),
274                    ty: TypeKind::Primitive(PrimitiveType::String),
275                    optional: false,
276                    rename: None,
277                    doc: None,
278                    skip: true,
279                    flatten: false,
280                },
281            ],
282            doc: None,
283            generics: vec![],
284        };
285
286        let output = m.emit_struct(&def);
287        assert!(output.contains("email: string;"));
288        assert!(!output.contains("password_hash"));
289    }
290
291    #[test]
292    fn test_renamed_field() {
293        let m = mapper();
294        let def = StructDef {
295            name: "User".to_string(),
296            fields: vec![FieldDef {
297                name: "user_name".to_string(),
298                ty: TypeKind::Primitive(PrimitiveType::String),
299                optional: false,
300                rename: Some("userName".to_string()),
301                doc: None,
302                skip: false,
303                flatten: false,
304            }],
305            doc: None,
306            generics: vec![],
307        };
308
309        let output = m.emit_struct(&def);
310        assert!(output.contains("userName: string;"));
311    }
312
313    #[test]
314    fn test_simple_enum() {
315        let m = mapper();
316        let def = EnumDef {
317            name: "Role".to_string(),
318            variants: vec![
319                VariantDef {
320                    name: "Admin".to_string(),
321                    rename: None,
322                    kind: VariantKind::Unit,
323                    doc: None,
324                },
325                VariantDef {
326                    name: "User".to_string(),
327                    rename: None,
328                    kind: VariantKind::Unit,
329                    doc: None,
330                },
331                VariantDef {
332                    name: "Guest".to_string(),
333                    rename: None,
334                    kind: VariantKind::Unit,
335                    doc: None,
336                },
337            ],
338            representation: EnumRepr::External,
339            doc: None,
340        };
341
342        let output = m.emit_enum(&def);
343        assert!(output.contains("export type Role ="));
344        assert!(output.contains("\"Admin\""));
345        assert!(output.contains("\"User\""));
346        assert!(output.contains("\"Guest\""));
347    }
348}