1use typewriter_core::ir::*;
4use typewriter_core::mapper::TypeMapper;
5
6use crate::emitter;
7
8pub struct TypeScriptMapper {
12 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 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
102fn 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 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}