baobao_codegen_typescript/
generator.rs

1//! TypeScript code generator using boune framework.
2
3use std::{collections::HashSet, path::Path};
4
5use baobao_codegen::{
6    builder::CodeBuilder,
7    generation::{FileEntry, FileRegistry, HandlerPaths, find_orphan_commands},
8    language::{CleanResult, GenerateResult, LanguageCodegen, PreviewFile},
9    schema::{CommandInfo, CommandTree, collect_context_fields},
10};
11use baobao_core::{GeneratedFile, to_camel_case, to_kebab_case};
12use baobao_manifest::{Command, Language, Manifest};
13use eyre::Result;
14
15use crate::{
16    adapters::BouneAdapter,
17    ast::{Import, JsObject},
18    files::{
19        BaoToml, CliTs, CommandTs, ContextTs, GitIgnore, HandlerTs, IndexTs, PackageJson, TsConfig,
20    },
21};
22
23/// Marker string indicating an unmodified TypeScript handler stub.
24const STUB_MARKER: &str = "// TODO: implement";
25
26/// TypeScript code generator that produces boune-based CLI code for Bun.
27pub struct Generator<'a> {
28    schema: &'a Manifest,
29    cli_adapter: BouneAdapter,
30}
31
32impl LanguageCodegen for Generator<'_> {
33    fn language(&self) -> &'static str {
34        "typescript"
35    }
36
37    fn file_extension(&self) -> &'static str {
38        "ts"
39    }
40
41    fn preview(&self) -> Vec<PreviewFile> {
42        self.preview_files()
43    }
44
45    fn generate(&self, output_dir: &Path) -> Result<GenerateResult> {
46        self.generate_files(output_dir)
47    }
48
49    fn clean(&self, output_dir: &Path) -> Result<CleanResult> {
50        self.clean_files(output_dir)
51    }
52
53    fn preview_clean(&self, output_dir: &Path) -> Result<CleanResult> {
54        self.preview_clean_files(output_dir)
55    }
56}
57
58impl<'a> Generator<'a> {
59    pub fn new(schema: &'a Manifest) -> Self {
60        Self {
61            schema,
62            cli_adapter: BouneAdapter::new(),
63        }
64    }
65
66    /// Build a file registry with all generated files.
67    ///
68    /// This centralizes file registration, making generation declarative.
69    fn build_registry(&self) -> FileRegistry {
70        let mut registry = FileRegistry::new();
71
72        // Collect context field info
73        let context_fields = collect_context_fields(&self.schema.context);
74
75        // Config files
76        registry.register(FileEntry::config(
77            "package.json",
78            PackageJson::new(&self.schema.cli.name)
79                .with_version(self.schema.cli.version.clone())
80                .render(),
81        ));
82        registry.register(FileEntry::config("tsconfig.json", TsConfig.render()));
83        registry.register(FileEntry::config(".gitignore", GitIgnore.render()));
84        registry.register(FileEntry::config(
85            "bao.toml",
86            BaoToml::new(&self.schema.cli.name, Language::TypeScript)
87                .with_version(self.schema.cli.version.clone())
88                .render(),
89        ));
90
91        // Infrastructure files
92        registry.register(FileEntry::infrastructure("src/index.ts", IndexTs.render()));
93        registry.register(FileEntry::infrastructure(
94            "src/context.ts",
95            ContextTs::new(context_fields).render(),
96        ));
97
98        // Generated files
99        let commands: Vec<CommandInfo> = self
100            .schema
101            .commands
102            .iter()
103            .map(|(name, cmd)| CommandInfo {
104                name: name.clone(),
105                description: cmd.description.clone(),
106                has_subcommands: cmd.has_subcommands(),
107            })
108            .collect();
109
110        registry.register(FileEntry::generated(
111            "src/cli.ts",
112            CliTs::new(
113                &self.schema.cli.name,
114                self.schema.cli.version.clone(),
115                self.schema.cli.description.clone(),
116                commands,
117            )
118            .render(),
119        ));
120
121        // Individual command files (recursively collect all commands)
122        for (name, command) in &self.schema.commands {
123            self.register_command_files(&mut registry, name, command, vec![name.clone()]);
124        }
125
126        registry
127    }
128
129    /// Recursively register command files in the registry.
130    fn register_command_files(
131        &self,
132        registry: &mut FileRegistry,
133        name: &str,
134        command: &Command,
135        path_segments: Vec<String>,
136    ) {
137        let content = self.generate_command_file(name, command, &path_segments);
138        let file_path = path_segments
139            .iter()
140            .map(|s| to_kebab_case(s))
141            .collect::<Vec<_>>()
142            .join("/");
143
144        registry.register(FileEntry::generated(
145            format!("src/commands/{}.ts", file_path),
146            CommandTs::nested(path_segments.clone(), content).render(),
147        ));
148
149        // Recursively register subcommand files
150        if command.has_subcommands() {
151            for (sub_name, sub_command) in &command.commands {
152                let mut sub_path = path_segments.clone();
153                sub_path.push(sub_name.clone());
154                self.register_command_files(registry, sub_name, sub_command, sub_path);
155            }
156        }
157    }
158
159    /// Preview generated files without writing to disk.
160    fn preview_files(&self) -> Vec<PreviewFile> {
161        self.build_registry()
162            .preview()
163            .into_iter()
164            .map(|entry| PreviewFile {
165                path: entry.path,
166                content: entry.content,
167            })
168            .collect()
169    }
170
171    /// Generate all files into the specified output directory.
172    fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
173        let handlers_dir = output_dir.join("src/handlers");
174
175        // Write all registered files using the registry
176        let registry = self.build_registry();
177        registry.write_all(output_dir)?;
178
179        // Generate handlers (handled separately due to special logic)
180        let result = self.generate_handlers(&handlers_dir, output_dir)?;
181
182        Ok(result)
183    }
184
185    fn generate_command_file(
186        &self,
187        name: &str,
188        command: &Command,
189        path_segments: &[String],
190    ) -> String {
191        if command.has_subcommands() {
192            self.generate_parent_command_file(name, command)
193        } else {
194            self.generate_leaf_command_file(name, command, path_segments)
195        }
196    }
197
198    /// Generate a parent command file that only routes to subcommands.
199    fn generate_parent_command_file(&self, name: &str, command: &Command) -> String {
200        use crate::code_file::{CodeFile, RawCode};
201
202        let camel_name = to_camel_case(name);
203        let kebab_name = to_kebab_case(name);
204
205        // Build imports
206        let mut imports = vec![Import::new("boune").named("defineCommand")];
207        for subcommand_name in command.commands.keys() {
208            let sub_camel = to_camel_case(subcommand_name);
209            let sub_kebab = to_kebab_case(subcommand_name);
210            imports.push(
211                Import::new(format!("./{}/{}.ts", kebab_name, sub_kebab))
212                    .named(format!("{}Command", sub_camel)),
213            );
214        }
215
216        // Build subcommands object
217        let subcommands = command
218            .commands
219            .keys()
220            .fold(JsObject::new(), |obj, sub_name| {
221                let sub_camel = to_camel_case(sub_name);
222                obj.raw(&sub_camel, format!("{}Command", sub_camel))
223            });
224
225        // Build command schema
226        let schema = JsObject::new()
227            .string("name", name)
228            .string("description", &command.description)
229            .object("subcommands", subcommands);
230
231        // Build the command definition string
232        let mut builder = CodeBuilder::typescript();
233        builder
234            .push_raw(&format!(
235                "export const {}Command = defineCommand(",
236                camel_name
237            ))
238            .emit(&schema)
239            .push_raw(");");
240        let command_def = builder.build();
241
242        CodeFile::new()
243            .imports(imports)
244            .add(RawCode::new(command_def))
245            .render()
246    }
247
248    /// Generate a leaf command file that has an action handler.
249    fn generate_leaf_command_file(
250        &self,
251        name: &str,
252        command: &Command,
253        path_segments: &[String],
254    ) -> String {
255        use baobao_core::to_pascal_case;
256
257        use crate::code_file::{CodeFile, RawCode};
258
259        let camel_name = to_camel_case(name);
260        let pascal_name = to_pascal_case(name);
261
262        // Build the handler path (kebab-case, joined by /)
263        let handler_path = path_segments
264            .iter()
265            .map(|s| to_kebab_case(s))
266            .collect::<Vec<_>>()
267            .join("/");
268
269        // Calculate relative path from command location
270        let depth = path_segments.len();
271        let up_path = "../".repeat(depth);
272
273        // Build imports
274        let mut boune_import = Import::new("boune").named("defineCommand");
275        if !command.args.is_empty() {
276            boune_import = boune_import.named("argument").named_type("InferArgs");
277        }
278        if !command.flags.is_empty() {
279            boune_import = boune_import.named("option").named_type("InferOptions");
280        }
281
282        let imports = vec![
283            boune_import,
284            Import::new(format!("{}handlers/{}.ts", up_path, handler_path)).named("run"),
285        ];
286
287        // Build body parts
288        let mut body_parts: Vec<String> = Vec::new();
289
290        // Arguments schema as const
291        if !command.args.is_empty() {
292            let arguments = command
293                .args
294                .iter()
295                .fold(JsObject::new(), |obj, (arg_name, arg)| {
296                    let camel = to_camel_case(arg_name);
297                    obj.raw(&camel, self.build_argument_chain(arg))
298                });
299
300            let mut builder = CodeBuilder::typescript();
301            builder
302                .push_raw("const args = ")
303                .emit(&arguments)
304                .push_line(" as const;");
305            body_parts.push(builder.build().trim_end().to_string());
306        }
307
308        // Options schema as const
309        if !command.flags.is_empty() {
310            let options = command
311                .flags
312                .iter()
313                .fold(JsObject::new(), |obj, (flag_name, flag)| {
314                    let camel = to_camel_case(flag_name);
315                    obj.raw(&camel, self.build_option_chain(flag))
316                });
317
318            let mut builder = CodeBuilder::typescript();
319            builder
320                .push_raw("const options = ")
321                .emit(&options)
322                .push_line(" as const;");
323            body_parts.push(builder.build().trim_end().to_string());
324        }
325
326        // Command definition
327        let command_def = self.build_command_definition(&camel_name, name, command);
328        body_parts.push(command_def);
329
330        // Export inferred types
331        let mut type_exports = Vec::new();
332        if !command.args.is_empty() {
333            type_exports.push(format!(
334                "export type {}Args = InferArgs<typeof args>;",
335                pascal_name
336            ));
337        }
338        if !command.flags.is_empty() {
339            type_exports.push(format!(
340                "export type {}Options = InferOptions<typeof options>;",
341                pascal_name
342            ));
343        }
344        if !type_exports.is_empty() {
345            body_parts.push(type_exports.join("\n"));
346        }
347
348        let mut file = CodeFile::new().imports(imports);
349        for part in body_parts {
350            file = file.add(RawCode::new(part));
351        }
352        file.render()
353    }
354
355    fn build_command_definition(&self, camel_name: &str, name: &str, command: &Command) -> String {
356        // Build action handler body
357        let action = self.build_action_handler(command);
358
359        // Build command schema - reference extracted consts
360        let schema = JsObject::new()
361            .string("name", name)
362            .string("description", &command.description)
363            .raw_if(!command.args.is_empty(), "arguments", "args")
364            .raw_if(!command.flags.is_empty(), "options", "options")
365            .arrow_fn("action", action);
366
367        let mut builder = CodeBuilder::typescript();
368        builder
369            .push_raw(&format!(
370                "export const {}Command = defineCommand(",
371                camel_name
372            ))
373            .emit(&schema)
374            .push_line(");");
375        builder.build().trim_end().to_string()
376    }
377
378    fn build_argument_chain(&self, arg: &baobao_manifest::Arg) -> String {
379        self.cli_adapter.build_argument_chain_manifest(
380            &arg.arg_type,
381            arg.required,
382            arg.default.is_some(),
383            arg.default.as_ref(),
384            arg.description.as_deref(),
385        )
386    }
387
388    fn build_option_chain(&self, flag: &baobao_manifest::Flag) -> String {
389        self.cli_adapter.build_option_chain_manifest(
390            &flag.flag_type,
391            flag.short_char(),
392            flag.default.as_ref(),
393            flag.description.as_deref(),
394        )
395    }
396
397    fn build_action_handler(&self, command: &Command) -> crate::ast::ArrowFn {
398        self.cli_adapter
399            .build_action_handler(!command.args.is_empty(), !command.flags.is_empty())
400    }
401
402    /// Generate handlers directory with stub files for missing handlers.
403    fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
404        use baobao_core::WriteResult;
405
406        let mut created_handlers = Vec::new();
407        let tree = CommandTree::new(self.schema);
408
409        // Collect all expected handler paths (kebab-case for TypeScript file names)
410        let expected_handlers: HashSet<String> = tree
411            .iter()
412            .map(|cmd| cmd.path_transformed("/", to_kebab_case))
413            .collect();
414
415        // Ensure handlers directory exists
416        std::fs::create_dir_all(handlers_dir)?;
417
418        // Create directories for all parent commands (command groups)
419        for cmd in tree.parents() {
420            let dir = cmd.handler_dir(handlers_dir, to_kebab_case);
421            std::fs::create_dir_all(&dir)?;
422        }
423
424        // Create stub files for all leaf commands (actual handlers)
425        for cmd in tree.leaves() {
426            let dir = cmd.handler_dir(handlers_dir, to_kebab_case);
427            std::fs::create_dir_all(&dir)?;
428
429            let display_path = cmd.path_transformed("/", to_kebab_case);
430            let path_segments = cmd.path.iter().map(|s| s.to_string()).collect();
431            let has_args = !cmd.command.args.is_empty();
432            let has_options = !cmd.command.flags.is_empty();
433
434            let stub = HandlerTs::nested(cmd.name, path_segments, has_args, has_options);
435            let result = stub.write(&dir)?;
436
437            if matches!(result, WriteResult::Written) {
438                created_handlers.push(format!("{}.ts", display_path));
439            }
440        }
441
442        // Find orphan handlers using shared utility
443        let handler_paths = HandlerPaths::new(handlers_dir, "ts", STUB_MARKER);
444        let orphan_handlers = handler_paths.find_orphans(&expected_handlers)?;
445
446        Ok(GenerateResult {
447            created_handlers,
448            orphan_handlers,
449        })
450    }
451
452    /// Clean orphaned generated files.
453    fn clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
454        let mut result = CleanResult::default();
455
456        // Collect expected command names (kebab-case for file names)
457        let expected_commands: HashSet<String> = self
458            .schema
459            .commands
460            .keys()
461            .map(|name| to_kebab_case(name))
462            .collect();
463
464        // Collect expected handler paths (kebab-case)
465        let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
466            .collect_paths()
467            .into_iter()
468            .map(|path| {
469                path.split('/')
470                    .map(to_kebab_case)
471                    .collect::<Vec<_>>()
472                    .join("/")
473            })
474            .collect();
475
476        // Find and delete orphaned generated command files
477        let commands_dir = output_dir.join("src/commands");
478        let orphan_commands = find_orphan_commands(&commands_dir, "ts", &expected_commands)?;
479        for path in orphan_commands {
480            std::fs::remove_file(&path)?;
481            let relative = path.strip_prefix(output_dir).unwrap_or(&path);
482            result.deleted_commands.push(relative.display().to_string());
483        }
484
485        // Find and handle orphaned handler files
486        let handlers_dir = output_dir.join("src/handlers");
487        let handler_paths = HandlerPaths::new(&handlers_dir, "ts", STUB_MARKER);
488        let orphan_handlers = handler_paths.find_orphans_with_status(&expected_handlers)?;
489
490        for orphan in orphan_handlers {
491            if orphan.is_unmodified {
492                // Safe to delete - it's still just a stub
493                std::fs::remove_file(&orphan.full_path)?;
494                result
495                    .deleted_handlers
496                    .push(format!("src/handlers/{}.ts", orphan.relative_path));
497
498                // Try to clean up empty parent directories
499                if let Some(parent) = orphan.full_path.parent() {
500                    let _ = Self::remove_empty_dirs(parent, &handlers_dir);
501                }
502            } else {
503                // User has modified this file, skip it
504                result
505                    .skipped_handlers
506                    .push(format!("src/handlers/{}.ts", orphan.relative_path));
507            }
508        }
509
510        Ok(result)
511    }
512
513    /// Preview what would be cleaned without actually deleting files.
514    fn preview_clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
515        let mut result = CleanResult::default();
516
517        // Collect expected command names (kebab-case for file names)
518        let expected_commands: HashSet<String> = self
519            .schema
520            .commands
521            .keys()
522            .map(|name| to_kebab_case(name))
523            .collect();
524
525        // Collect expected handler paths (kebab-case)
526        let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
527            .collect_paths()
528            .into_iter()
529            .map(|path| {
530                path.split('/')
531                    .map(to_kebab_case)
532                    .collect::<Vec<_>>()
533                    .join("/")
534            })
535            .collect();
536
537        // Find orphaned generated command files
538        let commands_dir = output_dir.join("src/commands");
539        let orphan_commands = find_orphan_commands(&commands_dir, "ts", &expected_commands)?;
540        for path in orphan_commands {
541            let relative = path.strip_prefix(output_dir).unwrap_or(&path);
542            result.deleted_commands.push(relative.display().to_string());
543        }
544
545        // Find orphaned handler files
546        let handlers_dir = output_dir.join("src/handlers");
547        let handler_paths = HandlerPaths::new(&handlers_dir, "ts", STUB_MARKER);
548        let orphan_handlers = handler_paths.find_orphans_with_status(&expected_handlers)?;
549
550        for orphan in orphan_handlers {
551            if orphan.is_unmodified {
552                result
553                    .deleted_handlers
554                    .push(format!("src/handlers/{}.ts", orphan.relative_path));
555            } else {
556                result
557                    .skipped_handlers
558                    .push(format!("src/handlers/{}.ts", orphan.relative_path));
559            }
560        }
561
562        Ok(result)
563    }
564
565    /// Remove empty directories up to but not including the base directory.
566    fn remove_empty_dirs(dir: &Path, base: &Path) -> Result<()> {
567        if dir == base || !dir.starts_with(base) {
568            return Ok(());
569        }
570
571        // Check if directory is empty
572        if std::fs::read_dir(dir)?.next().is_none() {
573            std::fs::remove_dir(dir)?;
574            // Try to remove parent too
575            if let Some(parent) = dir.parent() {
576                let _ = Self::remove_empty_dirs(parent, base);
577            }
578        }
579
580        Ok(())
581    }
582}