1use std::{collections::HashSet, path::Path};
4
5use baobao_codegen::{
6 CodeBuilder, CommandInfo, CommandTree, ContextFieldInfo, GenerateResult, HandlerPaths,
7 LanguageCodegen, PoolConfigInfo, PreviewFile, SqliteConfigInfo,
8};
9use baobao_core::{ContextFieldType, DatabaseType, GeneratedFile, to_camel_case, to_kebab_case};
10use baobao_manifest::{Command, Language, Manifest};
11use eyre::Result;
12
13use crate::{
14 ast::{ArrowFn, Import, JsObject},
15 files::{
16 BaoToml, CliTs, CommandTs, ContextTs, GitIgnore, HandlerTs, IndexTs, PackageJson, TsConfig,
17 },
18};
19
20pub struct Generator<'a> {
22 schema: &'a Manifest,
23}
24
25impl LanguageCodegen for Generator<'_> {
26 fn language(&self) -> &'static str {
27 "typescript"
28 }
29
30 fn file_extension(&self) -> &'static str {
31 "ts"
32 }
33
34 fn preview(&self) -> Vec<PreviewFile> {
35 self.preview_files()
36 }
37
38 fn generate(&self, output_dir: &Path) -> Result<GenerateResult> {
39 self.generate_files(output_dir)
40 }
41}
42
43impl<'a> Generator<'a> {
44 pub fn new(schema: &'a Manifest) -> Self {
45 Self { schema }
46 }
47
48 fn preview_files(&self) -> Vec<PreviewFile> {
50 let mut files = Vec::new();
51
52 let context_fields = self.collect_context_fields();
54
55 files.push(PreviewFile {
57 path: "src/context.ts".to_string(),
58 content: ContextTs::new(context_fields).render(),
59 });
60
61 files.push(PreviewFile {
63 path: "src/index.ts".to_string(),
64 content: IndexTs.render(),
65 });
66
67 files.push(PreviewFile {
69 path: "package.json".to_string(),
70 content: PackageJson::new(&self.schema.cli.name)
71 .with_version(self.schema.cli.version.clone())
72 .render(),
73 });
74
75 files.push(PreviewFile {
77 path: "tsconfig.json".to_string(),
78 content: TsConfig.render(),
79 });
80
81 files.push(PreviewFile {
83 path: ".gitignore".to_string(),
84 content: GitIgnore.render(),
85 });
86
87 let commands: Vec<CommandInfo> = self
89 .schema
90 .commands
91 .iter()
92 .map(|(name, cmd)| CommandInfo {
93 name: name.clone(),
94 description: cmd.description.clone(),
95 has_subcommands: cmd.has_subcommands(),
96 })
97 .collect();
98
99 files.push(PreviewFile {
100 path: "src/cli.ts".to_string(),
101 content: CliTs::new(
102 &self.schema.cli.name,
103 self.schema.cli.version.clone(),
104 self.schema.cli.description.clone(),
105 commands,
106 )
107 .render(),
108 });
109
110 for (name, command) in &self.schema.commands {
112 self.collect_command_previews(&mut files, name, command, vec![name.clone()]);
113 }
114
115 files
116 }
117
118 fn collect_command_previews(
120 &self,
121 files: &mut Vec<PreviewFile>,
122 name: &str,
123 command: &Command,
124 path_segments: Vec<String>,
125 ) {
126 let content = self.generate_command_file(name, command, &path_segments);
127 let file_path = path_segments
128 .iter()
129 .map(|s| to_kebab_case(s))
130 .collect::<Vec<_>>()
131 .join("/");
132
133 files.push(PreviewFile {
134 path: format!("src/commands/{}.ts", file_path),
135 content: CommandTs::nested(path_segments.clone(), content).render(),
136 });
137
138 if command.has_subcommands() {
140 for (sub_name, sub_command) in &command.commands {
141 let mut sub_path = path_segments.clone();
142 sub_path.push(sub_name.clone());
143 self.collect_command_previews(files, sub_name, sub_command, sub_path);
144 }
145 }
146 }
147
148 fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
150 let handlers_dir = output_dir.join("src").join("handlers");
151
152 let context_fields = self.collect_context_fields();
154
155 ContextTs::new(context_fields).write(output_dir)?;
157
158 IndexTs.write(output_dir)?;
160
161 PackageJson::new(&self.schema.cli.name)
163 .with_version(self.schema.cli.version.clone())
164 .write(output_dir)?;
165
166 TsConfig.write(output_dir)?;
168
169 GitIgnore.write(output_dir)?;
171
172 BaoToml::new(&self.schema.cli.name, Language::TypeScript)
174 .with_version(self.schema.cli.version.clone())
175 .write(output_dir)?;
176
177 let commands: Vec<CommandInfo> = self
179 .schema
180 .commands
181 .iter()
182 .map(|(name, cmd)| CommandInfo {
183 name: name.clone(),
184 description: cmd.description.clone(),
185 has_subcommands: cmd.has_subcommands(),
186 })
187 .collect();
188
189 CliTs::new(
190 &self.schema.cli.name,
191 self.schema.cli.version.clone(),
192 self.schema.cli.description.clone(),
193 commands,
194 )
195 .write(output_dir)?;
196
197 std::fs::create_dir_all(output_dir.join("src").join("commands"))?;
199
200 for (name, command) in &self.schema.commands {
202 self.generate_command_files_recursive(output_dir, name, command, vec![name.clone()])?;
203 }
204
205 let result = self.generate_handlers(&handlers_dir, output_dir)?;
207
208 Ok(result)
209 }
210
211 fn collect_context_fields(&self) -> Vec<ContextFieldInfo> {
212 use baobao_manifest::ContextField;
213
214 self.schema
215 .context
216 .fields()
217 .into_iter()
218 .map(|(name, field)| {
219 let env_var = field
220 .env()
221 .map(|s| s.to_string())
222 .unwrap_or_else(|| field.default_env().to_string());
223
224 let pool = field
225 .pool_config()
226 .map(|p| PoolConfigInfo {
227 max_connections: p.max_connections,
228 min_connections: p.min_connections,
229 acquire_timeout: p.acquire_timeout,
230 idle_timeout: p.idle_timeout,
231 max_lifetime: p.max_lifetime,
232 })
233 .unwrap_or_default();
234
235 let sqlite = field.sqlite_config().map(|s| SqliteConfigInfo {
236 path: s.path.clone(),
237 create_if_missing: s.create_if_missing,
238 read_only: s.read_only,
239 journal_mode: s.journal_mode.as_ref().map(|m| m.as_str().to_string()),
240 synchronous: s.synchronous.as_ref().map(|m| m.as_str().to_string()),
241 busy_timeout: s.busy_timeout,
242 foreign_keys: s.foreign_keys,
243 });
244
245 let field_type = match &field {
246 ContextField::Postgres(_) => ContextFieldType::Database(DatabaseType::Postgres),
247 ContextField::Mysql(_) => ContextFieldType::Database(DatabaseType::Mysql),
248 ContextField::Sqlite(_) => ContextFieldType::Database(DatabaseType::Sqlite),
249 ContextField::Http(_) => ContextFieldType::Http,
250 };
251
252 ContextFieldInfo {
253 name: name.to_string(),
254 field_type,
255 env_var,
256 is_async: field.is_async(),
257 pool,
258 sqlite,
259 }
260 })
261 .collect()
262 }
263
264 fn generate_command_files_recursive(
266 &self,
267 output_dir: &Path,
268 name: &str,
269 command: &Command,
270 path_segments: Vec<String>,
271 ) -> Result<()> {
272 let content = self.generate_command_file(name, command, &path_segments);
274
275 if path_segments.len() > 1 {
277 let mut dir_path = output_dir.join("src").join("commands");
278 for segment in &path_segments[..path_segments.len() - 1] {
279 dir_path = dir_path.join(to_kebab_case(segment));
280 }
281 std::fs::create_dir_all(&dir_path)?;
282 }
283
284 CommandTs::nested(path_segments.clone(), content).write(output_dir)?;
285
286 if command.has_subcommands() {
288 for (sub_name, sub_command) in &command.commands {
289 let mut sub_path = path_segments.clone();
290 sub_path.push(sub_name.clone());
291 self.generate_command_files_recursive(output_dir, sub_name, sub_command, sub_path)?;
292 }
293 }
294
295 Ok(())
296 }
297
298 fn generate_command_file(
299 &self,
300 name: &str,
301 command: &Command,
302 path_segments: &[String],
303 ) -> String {
304 if command.has_subcommands() {
305 self.generate_parent_command_file(name, command)
306 } else {
307 self.generate_leaf_command_file(name, command, path_segments)
308 }
309 }
310
311 fn generate_parent_command_file(&self, name: &str, command: &Command) -> String {
313 let camel_name = to_camel_case(name);
314 let kebab_name = to_kebab_case(name);
315
316 let mut builder = CodeBuilder::typescript();
317
318 builder = Import::new("boune").named("defineCommand").render(builder);
320
321 for subcommand_name in command.commands.keys() {
322 let sub_camel = to_camel_case(subcommand_name);
323 let sub_kebab = to_kebab_case(subcommand_name);
324 builder = Import::new(format!("./{}/{}.ts", kebab_name, sub_kebab))
325 .named(format!("{}Command", sub_camel))
326 .render(builder);
327 }
328
329 let subcommands = command
331 .commands
332 .keys()
333 .fold(JsObject::new(), |obj, sub_name| {
334 let sub_camel = to_camel_case(sub_name);
335 obj.raw(&sub_camel, format!("{}Command", sub_camel))
336 });
337
338 let schema = JsObject::new()
340 .string("name", name)
341 .string("description", &command.description)
342 .object("subcommands", subcommands);
343
344 builder = builder.blank().raw(&format!(
345 "export const {}Command = defineCommand(",
346 camel_name
347 ));
348 builder = schema.render(builder);
349 builder = builder.raw(");");
350
351 builder.build()
352 }
353
354 fn generate_leaf_command_file(
356 &self,
357 name: &str,
358 command: &Command,
359 path_segments: &[String],
360 ) -> String {
361 use baobao_core::to_pascal_case;
362
363 let camel_name = to_camel_case(name);
364 let pascal_name = to_pascal_case(name);
365
366 let handler_path = path_segments
368 .iter()
369 .map(|s| to_kebab_case(s))
370 .collect::<Vec<_>>()
371 .join("/");
372
373 let depth = path_segments.len();
375 let up_path = "../".repeat(depth);
376
377 let mut builder = CodeBuilder::typescript();
378
379 let mut boune_import = Import::new("boune").named("defineCommand");
381 if !command.args.is_empty() {
382 boune_import = boune_import.named("argument").named_type("InferArgs");
383 }
384 if !command.flags.is_empty() {
385 boune_import = boune_import.named("option").named_type("InferOptions");
386 }
387 builder = boune_import.render(builder);
388
389 builder = Import::new(format!("{}handlers/{}.ts", up_path, handler_path))
390 .named("run")
391 .render(builder);
392
393 if !command.args.is_empty() {
395 let arguments = command
396 .args
397 .iter()
398 .fold(JsObject::new(), |obj, (arg_name, arg)| {
399 let camel = to_camel_case(arg_name);
400 let boune_type = self.map_boune_type(&arg.arg_type);
401 obj.raw(&camel, self.build_argument_chain(boune_type, arg))
402 });
403
404 builder = builder.blank();
405 builder = builder.raw("const args = ");
406 builder = arguments.render(builder);
407 builder = builder.line(" as const;");
408 }
409
410 if !command.flags.is_empty() {
412 let options = command
413 .flags
414 .iter()
415 .fold(JsObject::new(), |obj, (flag_name, flag)| {
416 let camel = to_camel_case(flag_name);
417 let boune_type = self.map_boune_type(&flag.flag_type);
418 obj.raw(&camel, self.build_option_chain(boune_type, flag))
419 });
420
421 builder = builder.blank();
422 builder = builder.raw("const options = ");
423 builder = options.render(builder);
424 builder = builder.line(" as const;");
425 }
426
427 builder = builder.blank();
429 builder = self.render_command_definition(builder, &camel_name, name, command);
430
431 builder = builder.blank();
433 if !command.args.is_empty() {
434 builder = builder.line(&format!(
435 "export type {}Args = InferArgs<typeof args>;",
436 pascal_name
437 ));
438 }
439 if !command.flags.is_empty() {
440 builder = builder.line(&format!(
441 "export type {}Options = InferOptions<typeof options>;",
442 pascal_name
443 ));
444 }
445
446 builder.build()
447 }
448
449 fn render_command_definition(
450 &self,
451 builder: CodeBuilder,
452 camel_name: &str,
453 name: &str,
454 command: &Command,
455 ) -> CodeBuilder {
456 let action = self.build_action_handler(command);
458
459 let schema = JsObject::new()
461 .string("name", name)
462 .string("description", &command.description)
463 .raw_if(!command.args.is_empty(), "arguments", "args")
464 .raw_if(!command.flags.is_empty(), "options", "options")
465 .arrow_fn("action", action);
466
467 let builder = builder.raw(&format!(
468 "export const {}Command = defineCommand(",
469 camel_name
470 ));
471 let builder = schema.render(builder);
472 builder.line(");")
473 }
474
475 fn build_argument_chain(&self, boune_type: &str, arg: &baobao_manifest::Arg) -> String {
476 use crate::ast::MethodChain;
477
478 let mut chain = MethodChain::new(format!("argument.{}", boune_type));
479
480 if arg.required && arg.default.is_none() {
481 chain = chain.call_empty("required");
482 }
483
484 if let Some(default) = &arg.default {
485 chain = chain.call("default", Self::toml_to_ts_literal(default));
486 }
487
488 if let Some(desc) = &arg.description {
489 chain = chain.call("describe", format!("\"{}\"", desc));
490 }
491
492 chain.build_inline()
493 }
494
495 fn build_option_chain(&self, boune_type: &str, flag: &baobao_manifest::Flag) -> String {
496 use crate::ast::MethodChain;
497
498 let mut chain = MethodChain::new(format!("option.{}", boune_type));
499
500 if let Some(short) = flag.short_char() {
501 chain = chain.call("short", format!("\"{}\"", short));
502 }
503
504 if let Some(default) = &flag.default {
505 chain = chain.call("default", Self::toml_to_ts_literal(default));
506 }
507
508 if let Some(desc) = &flag.description {
509 chain = chain.call("describe", format!("\"{}\"", desc));
510 }
511
512 chain.build_inline()
513 }
514
515 fn toml_to_ts_literal(value: &toml::Value) -> String {
518 match value {
519 toml::Value::String(s) => format!("\"{}\"", s),
520 toml::Value::Integer(i) => i.to_string(),
521 toml::Value::Float(f) => f.to_string(),
522 toml::Value::Boolean(b) => b.to_string(),
523 _ => String::new(),
524 }
525 }
526
527 fn build_action_handler(&self, command: &Command) -> ArrowFn {
528 let has_args = !command.args.is_empty();
529 let has_options = !command.flags.is_empty();
530
531 let params = match (has_args, has_options) {
533 (true, true) => "{ args, options }",
534 (true, false) => "{ args }",
535 (false, true) => "{ options }",
536 (false, false) => "{}",
537 };
538
539 let run_call = match (has_args, has_options) {
541 (true, true) => "await run(args, options);",
542 (true, false) => "await run(args);",
543 (false, true) => "await run(options);",
544 (false, false) => "await run();",
545 };
546
547 ArrowFn::new(params).async_().body_line(run_call)
548 }
549
550 fn map_boune_type(&self, arg_type: &baobao_manifest::ArgType) -> &'static str {
551 use baobao_manifest::ArgType;
552 match arg_type {
553 ArgType::String => "string",
554 ArgType::Int => "number",
555 ArgType::Float => "number",
556 ArgType::Bool => "boolean",
557 ArgType::Path => "string",
558 }
559 }
560
561 fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
563 let mut created_handlers = Vec::new();
564
565 let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
567 .collect_paths()
568 .into_iter()
569 .map(|path| {
570 path.split('/')
571 .map(to_kebab_case)
572 .collect::<Vec<_>>()
573 .join("/")
574 })
575 .collect();
576
577 std::fs::create_dir_all(handlers_dir)?;
579
580 for (name, command) in &self.schema.commands {
582 let created =
583 self.generate_handler_stubs(handlers_dir, name, command, vec![name.clone()])?;
584 created_handlers.extend(created);
585 }
586
587 let handler_paths = HandlerPaths::new(handlers_dir, "ts");
589 let orphan_handlers = handler_paths.find_orphans(&expected_handlers)?;
590
591 Ok(GenerateResult {
592 created_handlers,
593 orphan_handlers,
594 })
595 }
596
597 fn generate_handler_stubs(
599 &self,
600 handlers_dir: &Path,
601 name: &str,
602 command: &Command,
603 path_segments: Vec<String>,
604 ) -> Result<Vec<String>> {
605 use baobao_core::WriteResult;
606
607 let mut created = Vec::new();
608
609 let kebab_name = to_kebab_case(name);
610 let display_path = path_segments
611 .iter()
612 .map(|s| to_kebab_case(s))
613 .collect::<Vec<_>>()
614 .join("/");
615
616 if command.has_subcommands() {
617 let subdir = handlers_dir.join(&kebab_name);
619 std::fs::create_dir_all(&subdir)?;
620
621 for (sub_name, sub_command) in &command.commands {
623 let mut sub_path = path_segments.clone();
624 sub_path.push(sub_name.clone());
625 let sub_created =
626 self.generate_handler_stubs(&subdir, sub_name, sub_command, sub_path)?;
627 created.extend(sub_created);
628 }
629 } else {
630 let has_args = !command.args.is_empty();
632 let has_options = !command.flags.is_empty();
633 let stub = HandlerTs::nested(name, path_segments, has_args, has_options);
634 let result = stub.write(handlers_dir)?;
635
636 if matches!(result, WriteResult::Written) {
637 created.push(format!("{}.ts", display_path));
638 }
639 }
640
641 Ok(created)
642 }
643}