baobao_codegen_typescript/
generator.rs1use 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
24pub 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 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 fn build_registry(&self) -> FileRegistry {
78 let mut registry = FileRegistry::new();
79
80 let context_fields = self.computed.context_fields.clone();
82
83 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 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 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 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 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 for child in &cmd.children {
140 self.register_command_files_from_ir(registry, child);
141 }
142 }
143
144 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 fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
158 let handlers_dir = output_dir.join("src/handlers");
159
160 let registry = self.build_registry();
162 registry.write_all(output_dir)?;
163
164 let result = self.generate_handlers(&handlers_dir, output_dir)?;
166
167 Ok(result)
168 }
169
170 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 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 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 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 let schema = JsObject::new()
209 .string("name", &cmd.name)
210 .string("description", &cmd.description)
211 .object("subcommands", subcommands);
212
213 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 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 let handler_path = cmd
236 .path
237 .iter()
238 .map(|s| to_kebab_case(s))
239 .collect::<Vec<_>>()
240 .join("/");
241
242 let depth = cmd.path.len();
244 let up_path = "../".repeat(depth);
245
246 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 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 let mut body_parts: Vec<String> = Vec::new();
272
273 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 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 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 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 let action = self.cli_adapter.build_action_handler(has_args, has_options);
342
343 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 fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
369 let mut created_handlers = Vec::new();
370
371 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 std::fs::create_dir_all(handlers_dir)?;
386
387 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 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 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 let cmd_dir = dir.join(to_kebab_case(&cmd.name));
423 std::fs::create_dir_all(&cmd_dir)?;
424
425 for child in &cmd.children {
427 self.generate_handlers_for_command(child, handlers_dir, created_handlers)?;
428 }
429 } else {
430 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 fn clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
462 let mut result = CleanResult::default();
463
464 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 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 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 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 std::fs::remove_file(&orphan.full_path)?;
506 result
507 .deleted_handlers
508 .push(format!("src/handlers/{}.ts", orphan.relative_path));
509
510 if let Some(parent) = orphan.full_path.parent() {
512 let _ = Self::remove_empty_dirs(parent, &handlers_dir);
513 }
514 } else {
515 result
517 .skipped_handlers
518 .push(format!("src/handlers/{}.ts", orphan.relative_path));
519 }
520 }
521
522 Ok(result)
523 }
524
525 fn preview_clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
527 let mut result = CleanResult::default();
528
529 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 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 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 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 fn remove_empty_dirs(dir: &Path, base: &Path) -> Result<()> {
583 if dir == base || !dir.starts_with(base) {
584 return Ok(());
585 }
586
587 if std::fs::read_dir(dir)?.next().is_none() {
589 std::fs::remove_dir(dir)?;
590 if let Some(parent) = dir.parent() {
592 let _ = Self::remove_empty_dirs(parent, base);
593 }
594 }
595
596 Ok(())
597 }
598}