1use 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
23const STUB_MARKER: &str = "// TODO: implement";
25
26pub 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 fn build_registry(&self) -> FileRegistry {
70 let mut registry = FileRegistry::new();
71
72 let context_fields = collect_context_fields(&self.schema.context);
74
75 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 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 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 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 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 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 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 fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
173 let handlers_dir = output_dir.join("src/handlers");
174
175 let registry = self.build_registry();
177 registry.write_all(output_dir)?;
178
179 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 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 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 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 let schema = JsObject::new()
227 .string("name", name)
228 .string("description", &command.description)
229 .object("subcommands", subcommands);
230
231 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 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 let handler_path = path_segments
264 .iter()
265 .map(|s| to_kebab_case(s))
266 .collect::<Vec<_>>()
267 .join("/");
268
269 let depth = path_segments.len();
271 let up_path = "../".repeat(depth);
272
273 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 let mut body_parts: Vec<String> = Vec::new();
289
290 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 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 let command_def = self.build_command_definition(&camel_name, name, command);
328 body_parts.push(command_def);
329
330 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 let action = self.build_action_handler(command);
358
359 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 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 let expected_handlers: HashSet<String> = tree
411 .iter()
412 .map(|cmd| cmd.path_transformed("/", to_kebab_case))
413 .collect();
414
415 std::fs::create_dir_all(handlers_dir)?;
417
418 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 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 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 fn clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
454 let mut result = CleanResult::default();
455
456 let expected_commands: HashSet<String> = self
458 .schema
459 .commands
460 .keys()
461 .map(|name| to_kebab_case(name))
462 .collect();
463
464 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 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 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 std::fs::remove_file(&orphan.full_path)?;
494 result
495 .deleted_handlers
496 .push(format!("src/handlers/{}.ts", orphan.relative_path));
497
498 if let Some(parent) = orphan.full_path.parent() {
500 let _ = Self::remove_empty_dirs(parent, &handlers_dir);
501 }
502 } else {
503 result
505 .skipped_handlers
506 .push(format!("src/handlers/{}.ts", orphan.relative_path));
507 }
508 }
509
510 Ok(result)
511 }
512
513 fn preview_clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
515 let mut result = CleanResult::default();
516
517 let expected_commands: HashSet<String> = self
519 .schema
520 .commands
521 .keys()
522 .map(|name| to_kebab_case(name))
523 .collect();
524
525 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 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 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 fn remove_empty_dirs(dir: &Path, base: &Path) -> Result<()> {
567 if dir == base || !dir.starts_with(base) {
568 return Ok(());
569 }
570
571 if std::fs::read_dir(dir)?.next().is_none() {
573 std::fs::remove_dir(dir)?;
574 if let Some(parent) = dir.parent() {
576 let _ = Self::remove_empty_dirs(parent, base);
577 }
578 }
579
580 Ok(())
581 }
582}