1use std::fmt;
2
3use std::path::PathBuf;
4
5use heck::AsPascalCase;
6use indexmap::IndexMap;
7
8use crate::spec::cmd::SpecCommand;
9use crate::spec::output::Selector;
10use crate::{Framing, Spec};
11
12pub mod python;
13pub mod typescript;
14
15#[derive(Debug, Clone)]
16pub enum SdkLanguage {
17 TypeScript,
18 Python,
19}
20
21#[derive(Debug, Clone)]
22pub struct SdkOptions {
23 pub language: SdkLanguage,
24 pub package_name: Option<String>,
25 pub source_file: Option<String>,
26}
27
28#[derive(Debug)]
29pub struct SdkOutput {
30 pub files: Vec<SdkFile>,
31}
32
33#[derive(Debug)]
34pub struct SdkFile {
35 pub path: PathBuf,
36 pub content: String,
37}
38
39pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput {
40 match opts.language {
41 SdkLanguage::TypeScript => typescript::generate(spec, opts),
42 SdkLanguage::Python => python::generate(spec, opts),
43 }
44}
45
46pub(crate) fn escape_jsdoc(s: &str) -> String {
48 s.replace("*/", r"*\/")
49}
50
51pub(crate) fn escape_py_docstring(s: &str) -> String {
53 s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
54}
55
56fn escape_string_literal(s: &str) -> String {
65 let mut out = String::with_capacity(s.len());
66 for c in s.chars() {
67 match c {
68 '\\' => out.push_str(r"\\"),
69 '"' => out.push_str(r#"\""#),
70 '\n' => out.push_str(r"\n"),
71 '\r' => out.push_str(r"\r"),
72 '\t' => out.push_str(r"\t"),
73 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
74 c => out.push(c),
75 }
76 }
77 out
78}
79
80pub(crate) fn escape_py_string(s: &str) -> String {
82 escape_string_literal(s)
83}
84
85pub(crate) fn escape_ts_string(s: &str) -> String {
87 escape_string_literal(s)
88}
89
90pub(crate) struct CodeWriter {
92 buf: String,
93 indent: usize,
94 indent_str: &'static str,
95}
96
97impl CodeWriter {
98 pub fn new() -> Self {
99 Self {
100 buf: String::new(),
101 indent: 0,
102 indent_str: " ",
103 }
104 }
105
106 pub fn with_indent(indent_str: &'static str) -> Self {
108 Self {
109 buf: String::new(),
110 indent: 0,
111 indent_str,
112 }
113 }
114
115 pub fn line(&mut self, s: &str) {
116 if !s.is_empty() {
117 for _ in 0..self.indent {
118 self.buf.push_str(self.indent_str);
119 }
120 }
121 self.buf.push_str(s);
122 self.buf.push('\n');
123 }
124
125 pub fn indent(&mut self) {
126 self.indent += 1;
127 }
128
129 pub fn dedent(&mut self) {
130 self.indent = self.indent.saturating_sub(1);
131 }
132
133 pub fn finish(self) -> String {
134 self.buf
135 }
136}
137
138impl fmt::Display for CodeWriter {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(f, "{}", self.buf)
141 }
142}
143
144pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> String {
145 match source {
146 Some(s) => {
147 format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.")
148 }
149 None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."),
150 }
151}
152
153pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String {
155 if cmd.name.is_empty() {
156 AsPascalCase(package_name).to_string()
157 } else {
158 AsPascalCase(&cmd.name).to_string()
159 }
160}
161
162pub(crate) fn command_path_type_name(cmd: &SpecCommand, package_name: &str) -> String {
164 if cmd.full_cmd.is_empty() {
165 command_type_name(cmd, package_name)
166 } else {
167 cmd.full_cmd
168 .iter()
169 .map(|part| AsPascalCase(part).to_string())
170 .collect()
171 }
172}
173
174pub(crate) struct OutputMethod {
185 pub suffix: String,
187 pub framing: Framing,
188 pub select: Vec<String>,
190 pub omit: Option<String>,
193 pub type_alias: String,
195 pub schema_const: Option<String>,
198 pub schema: Option<String>,
199 pub help: Option<String>,
200}
201
202pub(crate) fn output_methods(
210 cmd: &SpecCommand,
211 spec: &Spec,
212 package_name: &str,
213) -> Vec<OutputMethod> {
214 let chain = command_chain(spec, cmd);
215 let outputs = crate::spec::output::effective_outputs_ref(spec, chain.iter().copied());
216 let select = crate::spec::output::effective_select_ref(spec, &chain);
217 let machine: Vec<_> = outputs
218 .iter()
219 .filter(|o| o.framing != Framing::Text)
220 .collect();
221 let type_prefix = command_type_name(cmd, package_name);
222 machine
223 .iter()
224 .filter_map(|output| {
225 let selector = output.select_argv_with(select.as_deref())?;
226 let framing = output.framing.as_str();
227 let shares = machine
231 .iter()
232 .filter(|o| o.framing == output.framing)
233 .count()
234 > 1;
235 let suffix = if shares && !output.default {
236 format!("{framing}_{}", output.name.replace(['-', '.', ' '], "_"))
237 } else {
238 framing.to_string()
239 };
240 Some(OutputMethod {
241 type_alias: format!("{type_prefix}{}Output", AsPascalCase(&output.name)),
242 schema_const: output
243 .schema
244 .as_ref()
245 .map(|_| format!("{}_{}_SCHEMA", shouty(&type_prefix), shouty(&output.name))),
246 schema: output.schema.clone(),
247 omit: match &selector {
248 Selector::Value { flag, .. } => Some(flag.trim_start_matches('-').to_string()),
249 Selector::Present { .. } => None,
250 },
251 select: selector.argv(),
252 suffix,
253 framing: output.framing,
254 help: output.help.clone(),
255 })
256 })
257 .collect()
258}
259
260pub(crate) fn flag_names(flag: &crate::SpecFlag, selector: &str) -> bool {
262 let bare = selector.trim_start_matches('-');
263 flag.long.iter().any(|l| l == bare)
264 || flag.short.iter().any(|s| s.to_string() == bare)
265 || flag.name == bare
266}
267
268pub(crate) fn shouty(value: &str) -> String {
270 let separated = value
271 .chars()
272 .flat_map(|c| {
273 if c.is_uppercase() {
274 vec!['_', c]
275 } else if c == '-' || c == '.' || c == ' ' {
276 vec!['_']
277 } else {
278 vec![c.to_ascii_uppercase()]
279 }
280 })
281 .collect::<String>()
282 .trim_start_matches('_')
283 .to_string();
284 separated
285 .split('_')
286 .filter(|part| !part.is_empty())
287 .collect::<Vec<_>>()
288 .join("_")
289}
290
291pub(crate) fn exit_codes_for(cmd: &SpecCommand, spec: &Spec) -> Vec<crate::SpecExitCode> {
293 let chain = command_chain(spec, cmd);
294 crate::spec::exit_code::effective_exit_codes_ref(spec, chain.iter().copied())
295}
296
297fn command_chain<'a>(spec: &'a Spec, cmd: &'a SpecCommand) -> Vec<&'a SpecCommand> {
303 let mut current = &spec.cmd;
304 let mut chain = Vec::with_capacity(cmd.full_cmd.len());
305 for name in &cmd.full_cmd {
306 let Some(next) = current.subcommands.get(name) else {
307 return vec![cmd];
310 };
311 chain.push(next);
312 current = next;
313 }
314 chain
315}
316
317pub(crate) struct ChoiceTypeMap {
326 pub types: IndexMap<String, Vec<String>>,
328 name_map: IndexMap<(String, String), String>,
330}
331
332impl ChoiceTypeMap {
333 pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
337 self.name_map
338 .get(&(cmd_name.to_string(), item_name.to_string()))
339 .map(|s| s.as_str())
340 }
341
342 pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
344 self.types.iter()
345 }
346
347 pub fn is_empty(&self) -> bool {
349 self.types.is_empty()
350 }
351}
352
353struct ChoiceEntry {
354 base_name: String,
355 item_name: String,
356 cmd_name: String,
357 cmd_prefix: String,
358 choices: Vec<String>,
359}
360
361pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
365 let mut all_entries: Vec<ChoiceEntry> = Vec::new();
366 collect_choice_entries(cmd, &mut all_entries);
367
368 let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
370 for entry in &all_entries {
371 base_groups
372 .entry(entry.base_name.clone())
373 .or_default()
374 .push(entry);
375 }
376
377 let mut types = IndexMap::new();
378 let mut name_map = IndexMap::new();
379 for (base_name, entries) in &base_groups {
380 let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
381 if all_same {
382 types.insert(base_name.clone(), entries[0].choices.clone());
383 for entry in entries {
384 name_map.insert(
385 (entry.cmd_name.clone(), entry.item_name.clone()),
386 base_name.clone(),
387 );
388 }
389 } else {
390 for entry in entries {
391 let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
392 types.insert(prefixed.clone(), entry.choices.clone());
393 name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
394 }
395 }
396 }
397
398 ChoiceTypeMap { types, name_map }
399}
400
401fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
402 if cmd.hide {
403 return;
404 }
405
406 let cmd_prefix = if cmd.name.is_empty() {
407 String::new()
408 } else {
409 AsPascalCase(&cmd.name).to_string()
410 };
411 let cmd_name = cmd.name.clone();
412
413 for arg in &cmd.args {
414 if arg.hide {
415 continue;
416 }
417 if let Some(choices) = &arg.choices {
418 let base_name = format!("{}Choice", AsPascalCase(&arg.name));
419 entries.push(ChoiceEntry {
420 base_name,
421 item_name: arg.name.clone(),
422 cmd_name: cmd_name.clone(),
423 cmd_prefix: cmd_prefix.clone(),
424 choices: choices.choices.clone(),
425 });
426 }
427 }
428
429 for flag in &cmd.flags {
430 if flag.hide {
431 continue;
432 }
433 if let Some(arg) = &flag.arg {
434 if let Some(choices) = &arg.choices {
435 let base_name = format!("{}Choice", AsPascalCase(&flag.name));
436 entries.push(ChoiceEntry {
437 base_name,
438 item_name: flag.name.clone(),
439 cmd_name: cmd_name.clone(),
440 cmd_prefix: cmd_prefix.clone(),
441 choices: choices.choices.clone(),
442 });
443 }
444 }
445 }
446
447 for subcmd in cmd.subcommands.values() {
448 collect_choice_entries(subcmd, entries);
449 }
450}
451
452pub(crate) fn collect_type_imports(
454 cmd: &SpecCommand,
455 package_name: &str,
456 choice_types: &ChoiceTypeMap,
457 spec: &Spec,
458) -> Vec<String> {
459 let mut imports = Vec::new();
460 collect_type_imports_recursive(cmd, package_name, choice_types, spec, &mut imports);
461 imports.sort();
462 imports.dedup();
463 imports
464}
465
466fn collect_type_imports_recursive(
467 cmd: &SpecCommand,
468 package_name: &str,
469 choice_types: &ChoiceTypeMap,
470 spec: &Spec,
471 imports: &mut Vec<String>,
472) {
473 if cmd.hide {
474 return;
475 }
476
477 let name = command_type_name(cmd, package_name);
478 let has_args = cmd.args.iter().any(|a| !a.hide);
479 let has_flags = cmd.flags.iter().any(|f| !f.hide);
480
481 if has_args {
482 imports.push(format!("{name}Args"));
483 }
484 if has_flags {
485 imports.push(format!("{name}Flags"));
486 }
487
488 for arg in &cmd.args {
489 if !arg.hide && arg.choices.is_some() {
490 if let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name) {
491 imports.push(type_name.to_string());
492 }
493 }
494 }
495 for flag in &cmd.flags {
496 if !flag.hide {
497 if let Some(arg) = &flag.arg {
498 if arg.choices.is_some() {
499 if let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name) {
500 imports.push(type_name.to_string());
501 }
502 }
503 }
504 }
505 }
506
507 for output in output_methods(cmd, spec, package_name) {
510 imports.push(output.type_alias);
511 }
512
513 for subcmd in cmd.subcommands.values() {
514 collect_type_imports_recursive(subcmd, package_name, choice_types, spec, imports);
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_code_writer_display() {
524 let mut w = CodeWriter::with_indent(" ");
525 w.line("hello");
526 w.line("world");
527 let displayed = format!("{w}");
528 assert!(displayed.contains("hello"));
529 assert!(displayed.contains("world"));
530 }
531
532 #[test]
533 fn test_command_type_name_empty() {
534 let cmd = SpecCommand::default();
535 assert!(cmd.name.is_empty());
536 let result = command_type_name(&cmd, "mypackage");
537 assert_eq!(result, "Mypackage");
538 }
539
540 #[test]
541 fn shouty_collapses_every_separator_run() {
542 assert_eq!(shouty("a---b...c"), "A_B_C");
543 }
544
545 #[test]
546 fn test_generated_header_with_source() {
547 let result = generated_header("//", &Some("test.kdl".to_string()));
548 assert!(result.contains("test.kdl"));
549 }
550
551 #[test]
552 fn test_generated_header_without_source() {
553 let result = generated_header("//", &None);
554 assert!(!result.contains("test.kdl"));
555 assert!(result.contains("@generated"));
556 }
557
558 #[test]
561 fn test_hidden_command_with_choices() {
562 let spec: crate::Spec = r##"
563 bin "app"
564 cmd "visible" help="Visible" {
565 arg "env" help="Environment" {
566 choices "dev" "prod"
567 }
568 }
569 cmd "hidden" hide=#true help="Hidden" {
570 arg "mode" help="Mode" {
571 choices "fast" "slow"
572 }
573 flag "--level <n>" help="Level" {
574 choices "1" "2" "3"
575 }
576 }
577 "##
578 .parse()
579 .unwrap();
580 let choice_types = collect_choice_types(&spec.cmd);
581 assert!(choice_types.lookup("hidden", "mode").is_none());
583 assert!(choice_types.lookup("hidden", "level").is_none());
584 assert!(choice_types.lookup("visible", "env").is_some());
586 }
587
588 #[test]
590 fn test_hidden_arg_flag_with_choices() {
591 let spec: crate::Spec = r##"
592 bin "app"
593 arg "visible_choice" help="Visible" {
594 choices "a" "b"
595 }
596 arg "hidden_choice" hide=#true help="Hidden" {
597 choices "x" "y"
598 }
599 flag "--visible-flag <val>" help="Visible" {
600 choices "m" "n"
601 }
602 flag "--hidden-flag <val>" hide=#true help="Hidden" {
603 choices "p" "q"
604 }
605 "##
606 .parse()
607 .unwrap();
608 let choice_types = collect_choice_types(&spec.cmd);
609 assert!(choice_types.lookup("app", "visible_choice").is_some());
610 assert!(choice_types.lookup("app", "hidden_choice").is_none());
611 assert!(choice_types.lookup("app", "visible-flag").is_some());
612 assert!(choice_types.lookup("app", "hidden-flag").is_none());
613 }
614
615 #[test]
617 fn test_flag_arg_choices_import() {
618 let spec: crate::Spec = r##"
619 bin "app"
620 flag "--shell <shell>" help="Shell type" {
621 choices "bash" "zsh" "fish"
622 }
623 "##
624 .parse()
625 .unwrap();
626 let choice_types = collect_choice_types(&spec.cmd);
627 let mut imports = Vec::new();
628 collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &spec, &mut imports);
629 assert!(imports.iter().any(|i| i.contains("Choice")));
630 }
631
632 #[test]
633 fn nested_commands_inherit_outputs_and_exit_codes_through_the_full_path() {
634 let spec: crate::Spec = r#"
635 bin "app"
636 exit_code 130 "interrupted"
637 cmd "report" {
638 flag "--format <FORMAT>" global=#true
639 output "json" framing="json"
640 select "--format"
641 exit_code 2 "report failed"
642 cmd "watch" {
643 output "jsonl" framing="jsonl"
644 exit_code 3 "stream failed"
645 }
646 }
647 "#
648 .parse()
649 .unwrap();
650 let watch = &spec.cmd.subcommands["report"].subcommands["watch"];
651
652 let methods = output_methods(watch, &spec, "app");
653 assert_eq!(
654 methods
655 .iter()
656 .map(|method| method.framing)
657 .collect::<Vec<_>>(),
658 [Framing::Json, Framing::Jsonl]
659 );
660 assert_eq!(
661 exit_codes_for(watch, &spec)
662 .iter()
663 .map(|code| code.code)
664 .collect::<Vec<_>>(),
665 [130, 2, 3]
666 );
667 }
668}