baobao_codegen_typescript/
generator.rs

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