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