1use 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
23pub 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 fn build_registry(&self) -> FileRegistry {
67 let mut registry = FileRegistry::new();
68
69 let context_fields = collect_context_fields(&self.schema.context);
71
72 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 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 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 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 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 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 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 fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
164 let handlers_dir = output_dir.join("src/handlers");
165
166 let registry = self.build_registry();
168 registry.write_all(output_dir)?;
169
170 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 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 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 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 let schema = JsObject::new()
218 .string("name", name)
219 .string("description", &command.description)
220 .object("subcommands", subcommands);
221
222 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 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 let handler_path = path_segments
252 .iter()
253 .map(|s| to_kebab_case(s))
254 .collect::<Vec<_>>()
255 .join("/");
256
257 let depth = path_segments.len();
259 let up_path = "../".repeat(depth);
260
261 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 let mut body_parts: Vec<String> = Vec::new();
277
278 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 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 let command_def = self.build_command_definition(&camel_name, name, command);
308 body_parts.push(command_def);
309
310 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 let action = self.build_action_handler(command);
338
339 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 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 let expected_handlers: HashSet<String> = tree
389 .iter()
390 .map(|cmd| cmd.path_transformed("/", to_kebab_case))
391 .collect();
392
393 std::fs::create_dir_all(handlers_dir)?;
395
396 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 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 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 fn clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
432 let mut result = CleanResult::default();
433
434 let expected_commands: HashSet<String> = self
436 .schema
437 .commands
438 .keys()
439 .map(|name| to_kebab_case(name))
440 .collect();
441
442 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 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 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 std::fs::remove_file(&orphan.full_path)?;
472 result
473 .deleted_handlers
474 .push(format!("src/handlers/{}.ts", orphan.relative_path));
475
476 if let Some(parent) = orphan.full_path.parent() {
478 let _ = Self::remove_empty_dirs(parent, &handlers_dir);
479 }
480 } else {
481 result
483 .skipped_handlers
484 .push(format!("src/handlers/{}.ts", orphan.relative_path));
485 }
486 }
487
488 Ok(result)
489 }
490
491 fn preview_clean_files(&self, output_dir: &Path) -> Result<CleanResult> {
493 let mut result = CleanResult::default();
494
495 let expected_commands: HashSet<String> = self
497 .schema
498 .commands
499 .keys()
500 .map(|name| to_kebab_case(name))
501 .collect();
502
503 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 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 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 fn remove_empty_dirs(dir: &Path, base: &Path) -> Result<()> {
545 if dir == base || !dir.starts_with(base) {
546 return Ok(());
547 }
548
549 if std::fs::read_dir(dir)?.next().is_none() {
551 std::fs::remove_dir(dir)?;
552 if let Some(parent) = dir.parent() {
554 let _ = Self::remove_empty_dirs(parent, base);
555 }
556 }
557
558 Ok(())
559 }
560}