baobao_codegen_typescript/
generator.rs

1//! TypeScript code generator using boune framework.
2
3use std::{collections::HashSet, path::Path};
4
5use baobao_codegen::{
6    CodeBuilder, CommandInfo, CommandTree, ContextFieldInfo, GenerateResult, HandlerPaths,
7    LanguageCodegen, PoolConfigInfo, PreviewFile, SqliteConfigInfo,
8};
9use baobao_core::{ContextFieldType, DatabaseType, GeneratedFile, to_camel_case, to_kebab_case};
10use baobao_manifest::{Command, Language, Manifest};
11use eyre::Result;
12
13use crate::{
14    ast::{ArrowFn, Import, JsObject},
15    files::{
16        BaoToml, CliTs, CommandTs, ContextTs, GitIgnore, HandlerTs, IndexTs, PackageJson, TsConfig,
17    },
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 (recursively collect all commands)
111        for (name, command) in &self.schema.commands {
112            self.collect_command_previews(&mut files, name, command, vec![name.clone()]);
113        }
114
115        files
116    }
117
118    /// Recursively collect command file previews.
119    fn collect_command_previews(
120        &self,
121        files: &mut Vec<PreviewFile>,
122        name: &str,
123        command: &Command,
124        path_segments: Vec<String>,
125    ) {
126        let content = self.generate_command_file(name, command, &path_segments);
127        let file_path = path_segments
128            .iter()
129            .map(|s| to_kebab_case(s))
130            .collect::<Vec<_>>()
131            .join("/");
132
133        files.push(PreviewFile {
134            path: format!("src/commands/{}.ts", file_path),
135            content: CommandTs::nested(path_segments.clone(), content).render(),
136        });
137
138        // Recursively collect subcommand previews
139        if command.has_subcommands() {
140            for (sub_name, sub_command) in &command.commands {
141                let mut sub_path = path_segments.clone();
142                sub_path.push(sub_name.clone());
143                self.collect_command_previews(files, sub_name, sub_command, sub_path);
144            }
145        }
146    }
147
148    /// Generate all files into the specified output directory.
149    fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
150        let handlers_dir = output_dir.join("src").join("handlers");
151
152        // Collect context field info
153        let context_fields = self.collect_context_fields();
154
155        // Generate context.ts
156        ContextTs::new(context_fields).write(output_dir)?;
157
158        // Generate index.ts
159        IndexTs.write(output_dir)?;
160
161        // Generate package.json
162        PackageJson::new(&self.schema.cli.name)
163            .with_version(self.schema.cli.version.clone())
164            .write(output_dir)?;
165
166        // Generate tsconfig.json
167        TsConfig.write(output_dir)?;
168
169        // Generate .gitignore
170        GitIgnore.write(output_dir)?;
171
172        // Generate bao.toml
173        BaoToml::new(&self.schema.cli.name, Language::TypeScript)
174            .with_version(self.schema.cli.version.clone())
175            .write(output_dir)?;
176
177        // Generate cli.ts with main CLI setup
178        let commands: Vec<CommandInfo> = self
179            .schema
180            .commands
181            .iter()
182            .map(|(name, cmd)| CommandInfo {
183                name: name.clone(),
184                description: cmd.description.clone(),
185                has_subcommands: cmd.has_subcommands(),
186            })
187            .collect();
188
189        CliTs::new(
190            &self.schema.cli.name,
191            self.schema.cli.version.clone(),
192            self.schema.cli.description.clone(),
193            commands,
194        )
195        .write(output_dir)?;
196
197        // Ensure commands directory exists
198        std::fs::create_dir_all(output_dir.join("src").join("commands"))?;
199
200        // Generate individual command files (recursively for nested commands)
201        for (name, command) in &self.schema.commands {
202            self.generate_command_files_recursive(output_dir, name, command, vec![name.clone()])?;
203        }
204
205        // Generate handlers
206        let result = self.generate_handlers(&handlers_dir, output_dir)?;
207
208        Ok(result)
209    }
210
211    fn collect_context_fields(&self) -> Vec<ContextFieldInfo> {
212        use baobao_manifest::ContextField;
213
214        self.schema
215            .context
216            .fields()
217            .into_iter()
218            .map(|(name, field)| {
219                let env_var = field
220                    .env()
221                    .map(|s| s.to_string())
222                    .unwrap_or_else(|| field.default_env().to_string());
223
224                let pool = field
225                    .pool_config()
226                    .map(|p| PoolConfigInfo {
227                        max_connections: p.max_connections,
228                        min_connections: p.min_connections,
229                        acquire_timeout: p.acquire_timeout,
230                        idle_timeout: p.idle_timeout,
231                        max_lifetime: p.max_lifetime,
232                    })
233                    .unwrap_or_default();
234
235                let sqlite = field.sqlite_config().map(|s| SqliteConfigInfo {
236                    path: s.path.clone(),
237                    create_if_missing: s.create_if_missing,
238                    read_only: s.read_only,
239                    journal_mode: s.journal_mode.as_ref().map(|m| m.as_str().to_string()),
240                    synchronous: s.synchronous.as_ref().map(|m| m.as_str().to_string()),
241                    busy_timeout: s.busy_timeout,
242                    foreign_keys: s.foreign_keys,
243                });
244
245                let field_type = match &field {
246                    ContextField::Postgres(_) => ContextFieldType::Database(DatabaseType::Postgres),
247                    ContextField::Mysql(_) => ContextFieldType::Database(DatabaseType::Mysql),
248                    ContextField::Sqlite(_) => ContextFieldType::Database(DatabaseType::Sqlite),
249                    ContextField::Http(_) => ContextFieldType::Http,
250                };
251
252                ContextFieldInfo {
253                    name: name.to_string(),
254                    field_type,
255                    env_var,
256                    is_async: field.is_async(),
257                    pool,
258                    sqlite,
259                }
260            })
261            .collect()
262    }
263
264    /// Recursively generate command files for a command and all its subcommands.
265    fn generate_command_files_recursive(
266        &self,
267        output_dir: &Path,
268        name: &str,
269        command: &Command,
270        path_segments: Vec<String>,
271    ) -> Result<()> {
272        // Generate this command's file
273        let content = self.generate_command_file(name, command, &path_segments);
274
275        // Ensure parent directory exists for nested commands
276        if path_segments.len() > 1 {
277            let mut dir_path = output_dir.join("src").join("commands");
278            for segment in &path_segments[..path_segments.len() - 1] {
279                dir_path = dir_path.join(to_kebab_case(segment));
280            }
281            std::fs::create_dir_all(&dir_path)?;
282        }
283
284        CommandTs::nested(path_segments.clone(), content).write(output_dir)?;
285
286        // Recursively generate subcommand files
287        if command.has_subcommands() {
288            for (sub_name, sub_command) in &command.commands {
289                let mut sub_path = path_segments.clone();
290                sub_path.push(sub_name.clone());
291                self.generate_command_files_recursive(output_dir, sub_name, sub_command, sub_path)?;
292            }
293        }
294
295        Ok(())
296    }
297
298    fn generate_command_file(
299        &self,
300        name: &str,
301        command: &Command,
302        path_segments: &[String],
303    ) -> String {
304        if command.has_subcommands() {
305            self.generate_parent_command_file(name, command)
306        } else {
307            self.generate_leaf_command_file(name, command, path_segments)
308        }
309    }
310
311    /// Generate a parent command file that only routes to subcommands.
312    fn generate_parent_command_file(&self, name: &str, command: &Command) -> String {
313        let camel_name = to_camel_case(name);
314        let kebab_name = to_kebab_case(name);
315
316        let mut builder = CodeBuilder::typescript();
317
318        // Imports
319        builder = Import::new("boune").named("defineCommand").render(builder);
320
321        for subcommand_name in command.commands.keys() {
322            let sub_camel = to_camel_case(subcommand_name);
323            let sub_kebab = to_kebab_case(subcommand_name);
324            builder = Import::new(format!("./{}/{}.ts", kebab_name, sub_kebab))
325                .named(format!("{}Command", sub_camel))
326                .render(builder);
327        }
328
329        // Build subcommands object
330        let subcommands = command
331            .commands
332            .keys()
333            .fold(JsObject::new(), |obj, sub_name| {
334                let sub_camel = to_camel_case(sub_name);
335                obj.raw(&sub_camel, format!("{}Command", sub_camel))
336            });
337
338        // Build command schema
339        let schema = JsObject::new()
340            .string("name", name)
341            .string("description", &command.description)
342            .object("subcommands", subcommands);
343
344        builder = builder.blank().raw(&format!(
345            "export const {}Command = defineCommand(",
346            camel_name
347        ));
348        builder = schema.render(builder);
349        builder = builder.raw(");");
350
351        builder.build()
352    }
353
354    /// Generate a leaf command file that has an action handler.
355    fn generate_leaf_command_file(
356        &self,
357        name: &str,
358        command: &Command,
359        path_segments: &[String],
360    ) -> String {
361        use baobao_core::to_pascal_case;
362
363        let camel_name = to_camel_case(name);
364        let pascal_name = to_pascal_case(name);
365
366        // Build the handler path (kebab-case, joined by /)
367        let handler_path = path_segments
368            .iter()
369            .map(|s| to_kebab_case(s))
370            .collect::<Vec<_>>()
371            .join("/");
372
373        // Calculate relative path from command location
374        let depth = path_segments.len();
375        let up_path = "../".repeat(depth);
376
377        let mut builder = CodeBuilder::typescript();
378
379        // Imports
380        let mut boune_import = Import::new("boune").named("defineCommand");
381        if !command.args.is_empty() {
382            boune_import = boune_import.named("argument").named_type("InferArgs");
383        }
384        if !command.flags.is_empty() {
385            boune_import = boune_import.named("option").named_type("InferOptions");
386        }
387        builder = boune_import.render(builder);
388
389        builder = Import::new(format!("{}handlers/{}.ts", up_path, handler_path))
390            .named("run")
391            .render(builder);
392
393        // Extract arguments schema as const
394        if !command.args.is_empty() {
395            let arguments = command
396                .args
397                .iter()
398                .fold(JsObject::new(), |obj, (arg_name, arg)| {
399                    let camel = to_camel_case(arg_name);
400                    let boune_type = self.map_boune_type(&arg.arg_type);
401                    obj.raw(&camel, self.build_argument_chain(boune_type, arg))
402                });
403
404            builder = builder.blank();
405            builder = builder.raw("const args = ");
406            builder = arguments.render(builder);
407            builder = builder.line(" as const;");
408        }
409
410        // Extract options schema as const
411        if !command.flags.is_empty() {
412            let options = command
413                .flags
414                .iter()
415                .fold(JsObject::new(), |obj, (flag_name, flag)| {
416                    let camel = to_camel_case(flag_name);
417                    let boune_type = self.map_boune_type(&flag.flag_type);
418                    obj.raw(&camel, self.build_option_chain(boune_type, flag))
419                });
420
421            builder = builder.blank();
422            builder = builder.raw("const options = ");
423            builder = options.render(builder);
424            builder = builder.line(" as const;");
425        }
426
427        // Command definition
428        builder = builder.blank();
429        builder = self.render_command_definition(builder, &camel_name, name, command);
430
431        // Export inferred types
432        builder = builder.blank();
433        if !command.args.is_empty() {
434            builder = builder.line(&format!(
435                "export type {}Args = InferArgs<typeof args>;",
436                pascal_name
437            ));
438        }
439        if !command.flags.is_empty() {
440            builder = builder.line(&format!(
441                "export type {}Options = InferOptions<typeof options>;",
442                pascal_name
443            ));
444        }
445
446        builder.build()
447    }
448
449    fn render_command_definition(
450        &self,
451        builder: CodeBuilder,
452        camel_name: &str,
453        name: &str,
454        command: &Command,
455    ) -> CodeBuilder {
456        // Build action handler body
457        let action = self.build_action_handler(command);
458
459        // Build command schema - reference extracted consts
460        let schema = JsObject::new()
461            .string("name", name)
462            .string("description", &command.description)
463            .raw_if(!command.args.is_empty(), "arguments", "args")
464            .raw_if(!command.flags.is_empty(), "options", "options")
465            .arrow_fn("action", action);
466
467        let builder = builder.raw(&format!(
468            "export const {}Command = defineCommand(",
469            camel_name
470        ));
471        let builder = schema.render(builder);
472        builder.line(");")
473    }
474
475    fn build_argument_chain(&self, boune_type: &str, arg: &baobao_manifest::Arg) -> String {
476        use crate::ast::MethodChain;
477
478        let mut chain = MethodChain::new(format!("argument.{}", boune_type));
479
480        if arg.required && arg.default.is_none() {
481            chain = chain.call_empty("required");
482        }
483
484        if let Some(default) = &arg.default {
485            chain = chain.call("default", Self::toml_to_ts_literal(default));
486        }
487
488        if let Some(desc) = &arg.description {
489            chain = chain.call("describe", format!("\"{}\"", desc));
490        }
491
492        chain.build_inline()
493    }
494
495    fn build_option_chain(&self, boune_type: &str, flag: &baobao_manifest::Flag) -> String {
496        use crate::ast::MethodChain;
497
498        let mut chain = MethodChain::new(format!("option.{}", boune_type));
499
500        if let Some(short) = flag.short_char() {
501            chain = chain.call("short", format!("\"{}\"", short));
502        }
503
504        if let Some(default) = &flag.default {
505            chain = chain.call("default", Self::toml_to_ts_literal(default));
506        }
507
508        if let Some(desc) = &flag.description {
509            chain = chain.call("describe", format!("\"{}\"", desc));
510        }
511
512        chain.build_inline()
513    }
514
515    /// Convert a TOML value to a TypeScript literal string.
516    /// Strings are quoted, numbers and booleans are raw.
517    fn toml_to_ts_literal(value: &toml::Value) -> String {
518        match value {
519            toml::Value::String(s) => format!("\"{}\"", s),
520            toml::Value::Integer(i) => i.to_string(),
521            toml::Value::Float(f) => f.to_string(),
522            toml::Value::Boolean(b) => b.to_string(),
523            _ => String::new(),
524        }
525    }
526
527    fn build_action_handler(&self, command: &Command) -> ArrowFn {
528        let has_args = !command.args.is_empty();
529        let has_options = !command.flags.is_empty();
530
531        // Build destructuring pattern based on what's available
532        let params = match (has_args, has_options) {
533            (true, true) => "{ args, options }",
534            (true, false) => "{ args }",
535            (false, true) => "{ options }",
536            (false, false) => "{}",
537        };
538
539        // Build run() call based on what's available
540        let run_call = match (has_args, has_options) {
541            (true, true) => "await run(args, options);",
542            (true, false) => "await run(args);",
543            (false, true) => "await run(options);",
544            (false, false) => "await run();",
545        };
546
547        ArrowFn::new(params).async_().body_line(run_call)
548    }
549
550    fn map_boune_type(&self, arg_type: &baobao_manifest::ArgType) -> &'static str {
551        use baobao_manifest::ArgType;
552        match arg_type {
553            ArgType::String => "string",
554            ArgType::Int => "number",
555            ArgType::Float => "number",
556            ArgType::Bool => "boolean",
557            ArgType::Path => "string",
558        }
559    }
560
561    /// Generate handlers directory with stub files for missing handlers.
562    fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
563        let mut created_handlers = Vec::new();
564
565        // Collect all expected handler paths, using kebab-case for TypeScript file names
566        let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
567            .collect_paths()
568            .into_iter()
569            .map(|path| {
570                path.split('/')
571                    .map(to_kebab_case)
572                    .collect::<Vec<_>>()
573                    .join("/")
574            })
575            .collect();
576
577        // Ensure handlers directory exists
578        std::fs::create_dir_all(handlers_dir)?;
579
580        // Generate stub handlers for missing commands
581        for (name, command) in &self.schema.commands {
582            let created =
583                self.generate_handler_stubs(handlers_dir, name, command, vec![name.clone()])?;
584            created_handlers.extend(created);
585        }
586
587        // Find orphan handlers using shared utility
588        let handler_paths = HandlerPaths::new(handlers_dir, "ts");
589        let orphan_handlers = handler_paths.find_orphans(&expected_handlers)?;
590
591        Ok(GenerateResult {
592            created_handlers,
593            orphan_handlers,
594        })
595    }
596
597    /// Generate stub handler files for a command (recursively for subcommands).
598    fn generate_handler_stubs(
599        &self,
600        handlers_dir: &Path,
601        name: &str,
602        command: &Command,
603        path_segments: Vec<String>,
604    ) -> Result<Vec<String>> {
605        use baobao_core::WriteResult;
606
607        let mut created = Vec::new();
608
609        let kebab_name = to_kebab_case(name);
610        let display_path = path_segments
611            .iter()
612            .map(|s| to_kebab_case(s))
613            .collect::<Vec<_>>()
614            .join("/");
615
616        if command.has_subcommands() {
617            // Create directory for subcommands
618            let subdir = handlers_dir.join(&kebab_name);
619            std::fs::create_dir_all(&subdir)?;
620
621            // Recursively generate stubs for subcommands
622            for (sub_name, sub_command) in &command.commands {
623                let mut sub_path = path_segments.clone();
624                sub_path.push(sub_name.clone());
625                let sub_created =
626                    self.generate_handler_stubs(&subdir, sub_name, sub_command, sub_path)?;
627                created.extend(sub_created);
628            }
629        } else {
630            // Leaf command - generate stub if file doesn't exist
631            let has_args = !command.args.is_empty();
632            let has_options = !command.flags.is_empty();
633            let stub = HandlerTs::nested(name, path_segments, has_args, has_options);
634            let result = stub.write(handlers_dir)?;
635
636            if matches!(result, WriteResult::Written) {
637                created.push(format!("{}.ts", display_path));
638            }
639        }
640
641        Ok(created)
642    }
643}