1use typewriter_core::ir::*;
4use typewriter_core::mapper::TypeMapper;
5use typewriter_core::naming::{to_file_style, FileStyle};
6
7use crate::emitter;
8
9pub struct TypeScriptMapper {
13 pub readonly: bool,
15 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 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}