baobao_codegen_typescript/
generator.rs

1//! TypeScript code generator using boune framework.
2
3use std::{collections::HashSet, path::Path};
4
5use baobao_codegen::{
6    CommandInfo, CommandTree, ContextFieldInfo, GenerateResult, HandlerPaths, LanguageCodegen,
7    PoolConfigInfo, PreviewFile, SqliteConfigInfo,
8};
9use baobao_core::{
10    ContextFieldType, DatabaseType, GeneratedFile, to_camel_case, to_kebab_case, to_pascal_case,
11    toml_value_to_string,
12};
13use baobao_manifest::{ArgType, Command, Language, Manifest};
14use eyre::Result;
15
16use crate::files::{
17    BaoToml, CliTs, CommandTs, ContextTs, GitIgnore, HandlerTs, IndexTs, PackageJson, TsConfig,
18};
19
20/// TypeScript code generator that produces boune-based CLI code for Bun.
21pub struct Generator<'a> {
22    schema: &'a Manifest,
23}
24
25impl LanguageCodegen for Generator<'_> {
26    fn language(&self) -> &'static str {
27        "typescript"
28    }
29
30    fn file_extension(&self) -> &'static str {
31        "ts"
32    }
33
34    fn preview(&self) -> Vec<PreviewFile> {
35        self.preview_files()
36    }
37
38    fn generate(&self, output_dir: &Path) -> Result<GenerateResult> {
39        self.generate_files(output_dir)
40    }
41}
42
43impl<'a> Generator<'a> {
44    pub fn new(schema: &'a Manifest) -> Self {
45        Self { schema }
46    }
47
48    /// Preview generated files without writing to disk.
49    fn preview_files(&self) -> Vec<PreviewFile> {
50        let mut files = Vec::new();
51
52        // Collect context field info
53        let context_fields = self.collect_context_fields();
54
55        // context.ts
56        files.push(PreviewFile {
57            path: "src/context.ts".to_string(),
58            content: ContextTs::new(context_fields).render(),
59        });
60
61        // index.ts
62        files.push(PreviewFile {
63            path: "src/index.ts".to_string(),
64            content: IndexTs.render(),
65        });
66
67        // package.json
68        files.push(PreviewFile {
69            path: "package.json".to_string(),
70            content: PackageJson::new(&self.schema.cli.name)
71                .with_version(self.schema.cli.version.clone())
72                .render(),
73        });
74
75        // tsconfig.json
76        files.push(PreviewFile {
77            path: "tsconfig.json".to_string(),
78            content: TsConfig.render(),
79        });
80
81        // .gitignore
82        files.push(PreviewFile {
83            path: ".gitignore".to_string(),
84            content: GitIgnore.render(),
85        });
86
87        // cli.ts
88        let commands: Vec<CommandInfo> = self
89            .schema
90            .commands
91            .iter()
92            .map(|(name, cmd)| CommandInfo {
93                name: name.clone(),
94                description: cmd.description.clone(),
95                has_subcommands: cmd.has_subcommands(),
96            })
97            .collect();
98
99        files.push(PreviewFile {
100            path: "src/cli.ts".to_string(),
101            content: CliTs::new(
102                &self.schema.cli.name,
103                self.schema.cli.version.clone(),
104                self.schema.cli.description.clone(),
105                commands,
106            )
107            .render(),
108        });
109
110        // Individual command files
111        for (name, command) in &self.schema.commands {
112            let content = self.generate_command_file(name, command);
113            let file_name = to_kebab_case(name);
114            files.push(PreviewFile {
115                path: format!("src/commands/{}.ts", file_name),
116                content: CommandTs::new(name, content).render(),
117            });
118        }
119
120        files
121    }
122
123    /// Generate all files into the specified output directory.
124    fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
125        let handlers_dir = output_dir.join("src").join("handlers");
126
127        // Collect context field info
128        let context_fields = self.collect_context_fields();
129
130        // Generate context.ts
131        ContextTs::new(context_fields).write(output_dir)?;
132
133        // Generate index.ts
134        IndexTs.write(output_dir)?;
135
136        // Generate package.json
137        PackageJson::new(&self.schema.cli.name)
138            .with_version(self.schema.cli.version.clone())
139            .write(output_dir)?;
140
141        // Generate tsconfig.json
142        TsConfig.write(output_dir)?;
143
144        // Generate .gitignore
145        GitIgnore.write(output_dir)?;
146
147        // Generate bao.toml
148        BaoToml::new(&self.schema.cli.name, Language::TypeScript)
149            .with_version(self.schema.cli.version.clone())
150            .write(output_dir)?;
151
152        // Generate cli.ts with main CLI setup
153        let commands: Vec<CommandInfo> = self
154            .schema
155            .commands
156            .iter()
157            .map(|(name, cmd)| CommandInfo {
158                name: name.clone(),
159                description: cmd.description.clone(),
160                has_subcommands: cmd.has_subcommands(),
161            })
162            .collect();
163
164        CliTs::new(
165            &self.schema.cli.name,
166            self.schema.cli.version.clone(),
167            self.schema.cli.description.clone(),
168            commands,
169        )
170        .write(output_dir)?;
171
172        // Ensure commands directory exists
173        std::fs::create_dir_all(output_dir.join("src").join("commands"))?;
174
175        // Generate individual command files
176        for (name, command) in &self.schema.commands {
177            let content = self.generate_command_file(name, command);
178            CommandTs::new(name, content).write(output_dir)?;
179        }
180
181        // Generate handlers
182        let result = self.generate_handlers(&handlers_dir, output_dir)?;
183
184        Ok(result)
185    }
186
187    fn collect_context_fields(&self) -> Vec<ContextFieldInfo> {
188        use baobao_manifest::ContextField;
189
190        self.schema
191            .context
192            .fields()
193            .into_iter()
194            .map(|(name, field)| {
195                let env_var = field
196                    .env()
197                    .map(|s| s.to_string())
198                    .unwrap_or_else(|| field.default_env().to_string());
199
200                let pool = field
201                    .pool_config()
202                    .map(|p| PoolConfigInfo {
203                        max_connections: p.max_connections,
204                        min_connections: p.min_connections,
205                        acquire_timeout: p.acquire_timeout,
206                        idle_timeout: p.idle_timeout,
207                        max_lifetime: p.max_lifetime,
208                    })
209                    .unwrap_or_default();
210
211                let sqlite = field.sqlite_config().map(|s| SqliteConfigInfo {
212                    path: s.path.clone(),
213                    create_if_missing: s.create_if_missing,
214                    read_only: s.read_only,
215                    journal_mode: s.journal_mode.as_ref().map(|m| m.as_str().to_string()),
216                    synchronous: s.synchronous.as_ref().map(|m| m.as_str().to_string()),
217                    busy_timeout: s.busy_timeout,
218                    foreign_keys: s.foreign_keys,
219                });
220
221                let field_type = match &field {
222                    ContextField::Postgres(_) => ContextFieldType::Database(DatabaseType::Postgres),
223                    ContextField::Mysql(_) => ContextFieldType::Database(DatabaseType::Mysql),
224                    ContextField::Sqlite(_) => ContextFieldType::Database(DatabaseType::Sqlite),
225                    ContextField::Http(_) => ContextFieldType::Http,
226                };
227
228                ContextFieldInfo {
229                    name: name.to_string(),
230                    field_type,
231                    env_var,
232                    is_async: field.is_async(),
233                    pool,
234                    sqlite,
235                }
236            })
237            .collect()
238    }
239
240    fn generate_command_file(&self, name: &str, command: &Command) -> String {
241        let pascal_name = to_pascal_case(name);
242        let camel_name = to_camel_case(name);
243        let kebab_name = to_kebab_case(name);
244
245        let mut code = String::new();
246
247        // Imports
248        code.push_str("import { command } from \"boune\";\n");
249        code.push_str(&format!(
250            "import {{ run }} from \"../handlers/{}.ts\";\n",
251            kebab_name
252        ));
253        code.push_str("import type { Context } from \"../context.ts\";\n\n");
254
255        // Args interface
256        code.push_str(&self.generate_args_interface(&pascal_name, command));
257        code.push('\n');
258
259        // Command definition
260        code.push_str(&self.generate_command_definition(&pascal_name, &camel_name, name, command));
261
262        code
263    }
264
265    fn generate_args_interface(&self, pascal_name: &str, command: &Command) -> String {
266        let mut code = format!("export interface {}Args {{\n", pascal_name);
267
268        // Positional args
269        for (arg_name, arg) in &command.args {
270            let ts_type = self.map_arg_type(&arg.arg_type);
271            let camel_name = to_camel_case(arg_name);
272            if arg.required && arg.default.is_none() {
273                code.push_str(&format!("  {}: {};\n", camel_name, ts_type));
274            } else {
275                code.push_str(&format!("  {}?: {};\n", camel_name, ts_type));
276            }
277        }
278
279        // Flags
280        for (flag_name, flag) in &command.flags {
281            let ts_type = self.map_arg_type(&flag.flag_type);
282            let camel_name = to_camel_case(flag_name);
283            // Bool flags always have a value (default false), and flags with defaults are required
284            if flag.flag_type == ArgType::Bool || flag.default.is_some() {
285                code.push_str(&format!("  {}: {};\n", camel_name, ts_type));
286            } else {
287                code.push_str(&format!("  {}?: {};\n", camel_name, ts_type));
288            }
289        }
290
291        code.push_str("}\n");
292        code
293    }
294
295    fn generate_command_definition(
296        &self,
297        pascal_name: &str,
298        camel_name: &str,
299        name: &str,
300        command: &Command,
301    ) -> String {
302        let mut code = format!(
303            "export const {}Command = command(\"{}\")\n",
304            camel_name, name
305        );
306        code.push_str(&format!("  .description(\"{}\")\n", command.description));
307
308        // Positional args
309        for (arg_name, arg) in &command.args {
310            let bracket = if arg.required && arg.default.is_none() {
311                format!("<{}>", arg_name)
312            } else {
313                format!("[{}]", arg_name)
314            };
315            let desc = arg.description.as_deref().unwrap_or("");
316
317            if arg.arg_type != ArgType::String {
318                code.push_str(&format!(
319                    "  .argument(\"{}\", \"{}\", {{ type: \"{}\" }})\n",
320                    bracket,
321                    desc,
322                    self.map_boune_type(&arg.arg_type)
323                ));
324            } else {
325                code.push_str(&format!("  .argument(\"{}\", \"{}\")\n", bracket, desc));
326            }
327        }
328
329        // Flags
330        for (flag_name, flag) in &command.flags {
331            let short_part = flag
332                .short_char()
333                .map(|c| format!("-{}, ", c))
334                .unwrap_or_default();
335            let desc = flag.description.as_deref().unwrap_or("");
336
337            if flag.flag_type == ArgType::Bool {
338                code.push_str(&format!(
339                    "  .option(\"{}--{}\", \"{}\")\n",
340                    short_part, flag_name, desc
341                ));
342            } else {
343                let default_part = flag.default.as_ref().map_or(String::new(), |d| {
344                    format!(", default: {}", toml_value_to_string(d))
345                });
346                code.push_str(&format!(
347                    "  .option(\"{}--{} <{}>\", \"{}\", {{ type: \"{}\"{} }})\n",
348                    short_part,
349                    flag_name,
350                    flag_name,
351                    desc,
352                    self.map_boune_type(&flag.flag_type),
353                    default_part
354                ));
355            }
356        }
357
358        // Action handler
359        code.push_str("  .action(async ({ args, options }) => {\n");
360        code.push_str(&format!("    const typedArgs: {}Args = {{\n", pascal_name));
361
362        // Map args
363        for arg_name in command.args.keys() {
364            let camel = to_camel_case(arg_name);
365            code.push_str(&format!(
366                "      {}: args.{} as {}Args[\"{}\"],\n",
367                camel, arg_name, pascal_name, camel
368            ));
369        }
370
371        // Map flags
372        for (flag_name, flag) in &command.flags {
373            let camel = to_camel_case(flag_name);
374            if flag.flag_type == ArgType::Bool {
375                code.push_str(&format!(
376                    "      {}: (options.{} as boolean) ?? false,\n",
377                    camel, flag_name
378                ));
379            } else {
380                code.push_str(&format!(
381                    "      {}: options.{} as {}Args[\"{}\"],\n",
382                    camel, flag_name, pascal_name, camel
383                ));
384            }
385        }
386
387        code.push_str("    };\n");
388        code.push_str("    await run({} as Context, typedArgs);\n");
389        code.push_str("  });\n");
390
391        code
392    }
393
394    fn map_arg_type(&self, arg_type: &ArgType) -> &'static str {
395        match arg_type {
396            ArgType::String => "string",
397            ArgType::Int => "number",
398            ArgType::Float => "number",
399            ArgType::Bool => "boolean",
400            ArgType::Path => "string",
401        }
402    }
403
404    fn map_boune_type(&self, arg_type: &ArgType) -> &'static str {
405        match arg_type {
406            ArgType::String => "string",
407            ArgType::Int => "number",
408            ArgType::Float => "number",
409            ArgType::Bool => "boolean",
410            ArgType::Path => "string",
411        }
412    }
413
414    /// Generate handlers directory with stub files for missing handlers.
415    fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
416        let mut created_handlers = Vec::new();
417
418        // Collect all expected handler paths, using kebab-case for TypeScript file names
419        let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
420            .collect_paths()
421            .into_iter()
422            .map(|path| {
423                path.split('/')
424                    .map(to_kebab_case)
425                    .collect::<Vec<_>>()
426                    .join("/")
427            })
428            .collect();
429
430        // Ensure handlers directory exists
431        std::fs::create_dir_all(handlers_dir)?;
432
433        // Generate stub handlers for missing commands
434        for (name, command) in &self.schema.commands {
435            let created = self.generate_handler_stubs(handlers_dir, name, command, "")?;
436            created_handlers.extend(created);
437        }
438
439        // Find orphan handlers using shared utility
440        let handler_paths = HandlerPaths::new(handlers_dir, "ts");
441        let orphan_handlers = handler_paths.find_orphans(&expected_handlers)?;
442
443        Ok(GenerateResult {
444            created_handlers,
445            orphan_handlers,
446        })
447    }
448
449    /// Generate stub handler files for a command (recursively for subcommands).
450    fn generate_handler_stubs(
451        &self,
452        handlers_dir: &Path,
453        name: &str,
454        command: &Command,
455        prefix: &str,
456    ) -> Result<Vec<String>> {
457        use baobao_core::WriteResult;
458
459        let mut created = Vec::new();
460
461        let kebab_name = to_kebab_case(name);
462        let display_path = if prefix.is_empty() {
463            kebab_name.clone()
464        } else {
465            format!("{}/{}", prefix, kebab_name)
466        };
467
468        if command.has_subcommands() {
469            // Create directory for subcommands
470            let subdir = handlers_dir.join(&kebab_name);
471            std::fs::create_dir_all(&subdir)?;
472
473            // Recursively generate stubs for subcommands
474            for (sub_name, sub_command) in &command.commands {
475                let new_prefix = if prefix.is_empty() {
476                    kebab_name.clone()
477                } else {
478                    format!("{}/{}", prefix, kebab_name)
479                };
480                let sub_created =
481                    self.generate_handler_stubs(&subdir, sub_name, sub_command, &new_prefix)?;
482                created.extend(sub_created);
483            }
484        } else {
485            // Leaf command - generate stub if file doesn't exist
486            let pascal_name = to_pascal_case(name);
487            let stub = HandlerTs::new(name, format!("{}Args", pascal_name));
488            let result = stub.write(handlers_dir)?;
489
490            if matches!(result, WriteResult::Written) {
491                created.push(format!("{}.ts", display_path));
492            }
493        }
494
495        Ok(created)
496    }
497}