1use std::fmt;
2
3use std::path::PathBuf;
4
5use crate::case::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 typed_choices(arg: &crate::SpecArg) -> Option<&crate::SpecChoices> {
52 arg.choices
53 .as_ref()
54 .filter(|choices| choices.run().is_none())
55}
56
57pub(crate) fn escape_jsdoc(s: &str) -> String {
59 s.replace("*/", r"*\/")
60}
61
62pub(crate) fn escape_py_docstring(s: &str) -> String {
64 s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
65}
66
67fn escape_string_literal(s: &str) -> String {
76 let mut out = String::with_capacity(s.len());
77 for c in s.chars() {
78 match c {
79 '\\' => out.push_str(r"\\"),
80 '"' => out.push_str(r#"\""#),
81 '\n' => out.push_str(r"\n"),
82 '\r' => out.push_str(r"\r"),
83 '\t' => out.push_str(r"\t"),
84 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
85 c => out.push(c),
86 }
87 }
88 out
89}
90
91pub(crate) fn escape_py_string(s: &str) -> String {
93 escape_string_literal(s)
94}
95
96pub(crate) fn escape_ts_string(s: &str) -> String {
98 escape_string_literal(s)
99}
100
101pub(crate) struct CodeWriter {
103 buf: String,
104 indent: usize,
105 indent_str: &'static str,
106}
107
108impl CodeWriter {
109 pub fn new() -> Self {
110 Self {
111 buf: String::new(),
112 indent: 0,
113 indent_str: " ",
114 }
115 }
116
117 pub fn with_indent(indent_str: &'static str) -> Self {
119 Self {
120 buf: String::new(),
121 indent: 0,
122 indent_str,
123 }
124 }
125
126 pub fn line(&mut self, s: &str) {
127 if !s.is_empty() {
128 for _ in 0..self.indent {
129 self.buf.push_str(self.indent_str);
130 }
131 }
132 self.buf.push_str(s);
133 self.buf.push('\n');
134 }
135
136 pub fn indent(&mut self) {
137 self.indent += 1;
138 }
139
140 pub fn dedent(&mut self) {
141 self.indent = self.indent.saturating_sub(1);
142 }
143
144 pub fn finish(self) -> String {
145 self.buf
146 }
147}
148
149impl fmt::Display for CodeWriter {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(f, "{}", self.buf)
152 }
153}
154
155pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> String {
156 match source {
157 Some(s) => {
158 format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.")
159 }
160 None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."),
161 }
162}
163
164pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String {
166 if cmd.name.is_empty() {
167 AsPascalCase(package_name).to_string()
168 } else {
169 AsPascalCase(&cmd.name).to_string()
170 }
171}
172
173pub(crate) fn command_path_type_name(cmd: &SpecCommand, package_name: &str) -> String {
175 if cmd.full_cmd.is_empty() {
176 command_type_name(cmd, package_name)
177 } else {
178 cmd.full_cmd
179 .iter()
180 .map(|part| AsPascalCase(part).to_string())
181 .collect()
182 }
183}
184
185pub(crate) struct OutputMethod {
196 pub suffix: String,
198 pub framing: Framing,
199 pub select: Vec<String>,
201 pub omit: Option<String>,
204 pub type_alias: String,
206 pub schema_const: Option<String>,
209 pub schema: Option<String>,
210 pub help: Option<String>,
211}
212
213pub(crate) fn output_methods(
221 cmd: &SpecCommand,
222 spec: &Spec,
223 package_name: &str,
224) -> Vec<OutputMethod> {
225 let chain = command_chain(spec, cmd);
226 let outputs = crate::spec::output::effective_outputs_ref(spec, chain.iter().copied());
227 let select = crate::spec::output::effective_select_ref(spec, &chain);
228 let machine: Vec<_> = outputs
229 .iter()
230 .filter(|o| o.framing != Framing::Text)
231 .collect();
232 let type_prefix = command_type_name(cmd, package_name);
233 machine
234 .iter()
235 .filter_map(|output| {
236 let selector = output.select_argv_with(select.as_deref())?;
237 let framing = output.framing.as_str();
238 let shares = machine
242 .iter()
243 .filter(|o| o.framing == output.framing)
244 .count()
245 > 1;
246 let suffix = if shares && !output.default {
247 format!("{framing}_{}", output.name.replace(['-', '.', ' '], "_"))
248 } else {
249 framing.to_string()
250 };
251 Some(OutputMethod {
252 type_alias: format!("{type_prefix}{}Output", AsPascalCase(&output.name)),
253 schema_const: output
254 .schema
255 .as_ref()
256 .map(|_| format!("{}_{}_SCHEMA", shouty(&type_prefix), shouty(&output.name))),
257 schema: output.schema.clone(),
258 omit: match &selector {
259 Selector::Value { flag, .. } => Some(flag.trim_start_matches('-').to_string()),
260 Selector::Present { .. } => None,
261 },
262 select: selector.argv(),
263 suffix,
264 framing: output.framing,
265 help: output.help.clone(),
266 })
267 })
268 .collect()
269}
270
271pub(crate) fn flag_names(flag: &crate::SpecFlag, selector: &str) -> bool {
273 let bare = selector.trim_start_matches('-');
274 flag.long.iter().any(|l| l == bare)
275 || flag.short.iter().any(|s| s.to_string() == bare)
276 || flag.name == bare
277}
278
279pub(crate) fn shouty(value: &str) -> String {
281 let separated = value
282 .chars()
283 .flat_map(|c| {
284 if c.is_uppercase() {
285 vec!['_', c]
286 } else if c == '-' || c == '.' || c == ' ' {
287 vec!['_']
288 } else {
289 vec![c.to_ascii_uppercase()]
290 }
291 })
292 .collect::<String>()
293 .trim_start_matches('_')
294 .to_string();
295 separated
296 .split('_')
297 .filter(|part| !part.is_empty())
298 .collect::<Vec<_>>()
299 .join("_")
300}
301
302pub(crate) fn exit_codes_for(cmd: &SpecCommand, spec: &Spec) -> Vec<crate::SpecExitCode> {
304 let chain = command_chain(spec, cmd);
305 crate::spec::exit_code::effective_exit_codes_ref(spec, chain.iter().copied())
306}
307
308fn command_chain<'a>(spec: &'a Spec, cmd: &'a SpecCommand) -> Vec<&'a SpecCommand> {
314 let mut current = &spec.cmd;
315 let mut chain = Vec::with_capacity(cmd.full_cmd.len());
316 for name in &cmd.full_cmd {
317 let Some(next) = current.subcommands.get(name) else {
318 return vec![cmd];
321 };
322 chain.push(next);
323 current = next;
324 }
325 chain
326}
327
328pub(crate) struct ChoiceTypeMap {
337 pub types: IndexMap<String, Vec<String>>,
339 name_map: IndexMap<(String, String), String>,
341}
342
343impl ChoiceTypeMap {
344 pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
348 self.name_map
349 .get(&(cmd_name.to_string(), item_name.to_string()))
350 .map(|s| s.as_str())
351 }
352
353 pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
355 self.types.iter()
356 }
357
358 pub fn is_empty(&self) -> bool {
360 self.types.is_empty()
361 }
362}
363
364struct ChoiceEntry {
365 base_name: String,
366 item_name: String,
367 cmd_name: String,
368 cmd_prefix: String,
369 choices: Vec<String>,
370}
371
372pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
376 let mut all_entries: Vec<ChoiceEntry> = Vec::new();
377 collect_choice_entries(cmd, &mut all_entries);
378
379 let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
381 for entry in &all_entries {
382 base_groups
383 .entry(entry.base_name.clone())
384 .or_default()
385 .push(entry);
386 }
387
388 let mut types = IndexMap::new();
389 let mut name_map = IndexMap::new();
390 for (base_name, entries) in &base_groups {
391 let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
392 if all_same {
393 types.insert(base_name.clone(), entries[0].choices.clone());
394 for entry in entries {
395 name_map.insert(
396 (entry.cmd_name.clone(), entry.item_name.clone()),
397 base_name.clone(),
398 );
399 }
400 } else {
401 for entry in entries {
402 let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
403 types.insert(prefixed.clone(), entry.choices.clone());
404 name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
405 }
406 }
407 }
408
409 ChoiceTypeMap { types, name_map }
410}
411
412fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
413 if cmd.hide {
414 return;
415 }
416
417 let cmd_prefix = if cmd.name.is_empty() {
418 String::new()
419 } else {
420 AsPascalCase(&cmd.name).to_string()
421 };
422 let cmd_name = cmd.name.clone();
423
424 for arg in &cmd.args {
425 if arg.hide {
426 continue;
427 }
428 if let Some(choices) = typed_choices(arg) {
429 let base_name = format!("{}Choice", AsPascalCase(&arg.name));
430 entries.push(ChoiceEntry {
431 base_name,
432 item_name: arg.name.clone(),
433 cmd_name: cmd_name.clone(),
434 cmd_prefix: cmd_prefix.clone(),
435 choices: choices.choices.clone(),
436 });
437 }
438 }
439
440 for flag in &cmd.flags {
441 if flag.hide {
442 continue;
443 }
444 if let Some(arg) = &flag.arg
445 && let Some(choices) = typed_choices(arg)
446 {
447 let base_name = format!("{}Choice", AsPascalCase(&flag.name));
448 entries.push(ChoiceEntry {
449 base_name,
450 item_name: flag.name.clone(),
451 cmd_name: cmd_name.clone(),
452 cmd_prefix: cmd_prefix.clone(),
453 choices: choices.choices.clone(),
454 });
455 }
456 }
457
458 for subcmd in cmd.subcommands.values() {
459 collect_choice_entries(subcmd, entries);
460 }
461}
462
463pub(crate) fn collect_type_imports(
465 cmd: &SpecCommand,
466 package_name: &str,
467 choice_types: &ChoiceTypeMap,
468 spec: &Spec,
469) -> Vec<String> {
470 let mut imports = Vec::new();
471 collect_type_imports_recursive(cmd, package_name, choice_types, spec, &mut imports);
472 imports.sort();
473 imports.dedup();
474 imports
475}
476
477fn collect_type_imports_recursive(
478 cmd: &SpecCommand,
479 package_name: &str,
480 choice_types: &ChoiceTypeMap,
481 spec: &Spec,
482 imports: &mut Vec<String>,
483) {
484 if cmd.hide {
485 return;
486 }
487
488 let name = command_type_name(cmd, package_name);
489 let has_args = cmd.args.iter().any(|a| !a.hide);
490 let has_flags = cmd.flags.iter().any(|f| !f.hide);
491
492 if has_args {
493 imports.push(format!("{name}Args"));
494 }
495 if has_flags {
496 imports.push(format!("{name}Flags"));
497 }
498
499 for arg in &cmd.args {
500 if !arg.hide
501 && typed_choices(arg).is_some()
502 && let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name)
503 {
504 imports.push(type_name.to_string());
505 }
506 }
507 for flag in &cmd.flags {
508 if !flag.hide
509 && let Some(arg) = &flag.arg
510 && typed_choices(arg).is_some()
511 && let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name)
512 {
513 imports.push(type_name.to_string());
514 }
515 }
516
517 for output in output_methods(cmd, spec, package_name) {
520 imports.push(output.type_alias);
521 }
522
523 for subcmd in cmd.subcommands.values() {
524 collect_type_imports_recursive(subcmd, package_name, choice_types, spec, imports);
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn test_code_writer_display() {
534 let mut w = CodeWriter::with_indent(" ");
535 w.line("hello");
536 w.line("world");
537 let displayed = format!("{w}");
538 assert!(displayed.contains("hello"));
539 assert!(displayed.contains("world"));
540 }
541
542 #[test]
543 fn test_command_type_name_empty() {
544 let cmd = SpecCommand::default();
545 assert!(cmd.name.is_empty());
546 let result = command_type_name(&cmd, "mypackage");
547 assert_eq!(result, "Mypackage");
548 }
549
550 #[test]
551 fn shouty_collapses_every_separator_run() {
552 assert_eq!(shouty("a---b...c"), "A_B_C");
553 }
554
555 #[test]
556 fn test_generated_header_with_source() {
557 let result = generated_header("//", &Some("test.kdl".to_string()));
558 assert!(result.contains("test.kdl"));
559 }
560
561 #[test]
562 fn test_generated_header_without_source() {
563 let result = generated_header("//", &None);
564 assert!(!result.contains("test.kdl"));
565 assert!(result.contains("@generated"));
566 }
567
568 #[test]
571 fn test_hidden_command_with_choices() {
572 let spec: crate::Spec = r##"
573 bin "app"
574 cmd "visible" help="Visible" {
575 arg "env" help="Environment" {
576 choices "dev" "prod"
577 }
578 }
579 cmd "hidden" hide=#true help="Hidden" {
580 arg "mode" help="Mode" {
581 choices "fast" "slow"
582 }
583 flag "--level <n>" help="Level" {
584 choices "1" "2" "3"
585 }
586 }
587 "##
588 .parse()
589 .unwrap();
590 let choice_types = collect_choice_types(&spec.cmd);
591 assert!(choice_types.lookup("hidden", "mode").is_none());
593 assert!(choice_types.lookup("hidden", "level").is_none());
594 assert!(choice_types.lookup("visible", "env").is_some());
596 }
597
598 #[test]
600 fn test_hidden_arg_flag_with_choices() {
601 let spec: crate::Spec = r##"
602 bin "app"
603 arg "visible_choice" help="Visible" {
604 choices "a" "b"
605 }
606 arg "hidden_choice" hide=#true help="Hidden" {
607 choices "x" "y"
608 }
609 flag "--visible-flag <val>" help="Visible" {
610 choices "m" "n"
611 }
612 flag "--hidden-flag <val>" hide=#true help="Hidden" {
613 choices "p" "q"
614 }
615 "##
616 .parse()
617 .unwrap();
618 let choice_types = collect_choice_types(&spec.cmd);
619 assert!(choice_types.lookup("app", "visible_choice").is_some());
620 assert!(choice_types.lookup("app", "hidden_choice").is_none());
621 assert!(choice_types.lookup("app", "visible-flag").is_some());
622 assert!(choice_types.lookup("app", "hidden-flag").is_none());
623 }
624
625 #[test]
627 fn test_flag_arg_choices_import() {
628 let spec: crate::Spec = r##"
629 bin "app"
630 flag "--shell <shell>" help="Shell type" {
631 choices "bash" "zsh" "fish"
632 }
633 "##
634 .parse()
635 .unwrap();
636 let choice_types = collect_choice_types(&spec.cmd);
637 let mut imports = Vec::new();
638 collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &spec, &mut imports);
639 assert!(imports.iter().any(|i| i.contains("Choice")));
640 }
641
642 #[test]
643 fn nested_commands_inherit_outputs_and_exit_codes_through_the_full_path() {
644 let spec: crate::Spec = r#"
645 bin "app"
646 exit_code 130 "interrupted"
647 cmd "report" {
648 flag "--format <FORMAT>" global=#true
649 output "json" framing="json"
650 select "--format"
651 exit_code 2 "report failed"
652 cmd "watch" {
653 output "jsonl" framing="jsonl"
654 exit_code 3 "stream failed"
655 }
656 }
657 "#
658 .parse()
659 .unwrap();
660 let watch = &spec.cmd.subcommands["report"].subcommands["watch"];
661
662 let methods = output_methods(watch, &spec, "app");
663 assert_eq!(
664 methods
665 .iter()
666 .map(|method| method.framing)
667 .collect::<Vec<_>>(),
668 [Framing::Json, Framing::Jsonl]
669 );
670 assert_eq!(
671 exit_codes_for(watch, &spec)
672 .iter()
673 .map(|code| code.code)
674 .collect::<Vec<_>>(),
675 [130, 2, 3]
676 );
677 }
678}