Skip to main content

usage/sdk/
mod.rs

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
46/// The choices a generated type can spell out as a union of literals.
47///
48/// None for `choices run=`: its values come from a command where the program runs, so a
49/// type fixed at generation time would refuse ones the program accepts. Such a value is
50/// typed as a plain string.
51pub(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
57/// Escape JSDoc-terminating sequences in comment text.
58pub(crate) fn escape_jsdoc(s: &str) -> String {
59    s.replace("*/", r"*\/")
60}
61
62/// Escape triple-quote sequences and backslashes in Python docstrings.
63pub(crate) fn escape_py_docstring(s: &str) -> String {
64    s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
65}
66
67/// Escape a string for a double-quoted literal.
68///
69/// Backslashes, quotes, and control characters — the last of these because neither language
70/// can carry one literally inside a quoted string, so a value with a newline in it wrote a
71/// module that fails to import rather than one that says something wrong. Help text and
72/// config defaults both really do contain them.
73///
74/// Python and TypeScript spell all of these the same way, so one function serves both.
75fn 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
91/// Escape a string for a Python literal.
92pub(crate) fn escape_py_string(s: &str) -> String {
93    escape_string_literal(s)
94}
95
96/// Escape a string for a TypeScript literal.
97pub(crate) fn escape_ts_string(s: &str) -> String {
98    escape_string_literal(s)
99}
100
101/// A simple code writer with indentation management.
102pub(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    /// Create a CodeWriter with custom indent string (e.g. "    " for Python).
118    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
164/// Returns the PascalCase type name for a command: the command name, or the package name for root.
165pub(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
173/// A command type name qualified by its full path, for declarations emitted at module scope.
174pub(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
185// ---------------------------------------------------------------------------
186// Declared outputs, as generated client methods
187// ---------------------------------------------------------------------------
188
189/// One extra method on a generated command class, for one declared output.
190///
191/// `exec()` is never changed: a caller who wants raw text keeps getting it. What framing
192/// buys is a method whose *shape* matches the wire format, because `json` is read to the
193/// end and parsed once while `jsonl` arrives a line at a time and may never finish. Those
194/// are different signatures, not different parse calls.
195pub(crate) struct OutputMethod {
196    /// Appended to `exec`, so `exec_jsonl` / `execJsonl`.
197    pub suffix: String,
198    pub framing: Framing,
199    /// The words that pick this output, ready to append to an argv.
200    pub select: Vec<String>,
201    /// The selecting flag's property name on the flags bag, so a caller-supplied value of
202    /// it can be left out rather than duplicated on the command line.
203    pub omit: Option<String>,
204    /// The generated type alias a parsed value flows through.
205    pub type_alias: String,
206    /// The generated constant holding the schema, and the schema itself, where one was
207    /// declared.
208    pub schema_const: Option<String>,
209    pub schema: Option<String>,
210    pub help: Option<String>,
211}
212
213/// The methods a command's declared outputs earn it.
214///
215/// Named after the **framing**, not the output's own token. That is the whole point of the
216/// split: hk spells its line-delimited output `jsonl` and aube spells the identical format
217/// `ndjson`, so `exec_ndjson()` for one and `exec_jsonl()` for the other would put the
218/// per-CLI spelling back into every caller. The token goes into the argv this builds; the
219/// caller never types it.
220pub(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            // Several outputs can share a framing — a CLI with both `json` and
239            // `json-compact`. The default one keeps the plain name and the rest are
240            // suffixed, so the common call stays short and none of them collide.
241            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
271/// Whether a flag answers to a selector spelling, dashes or not.
272pub(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
279/// SHOUTY_SNAKE, for the generated constant names.
280pub(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
302/// The exit codes a generated client should document, folded from the spec's.
303pub(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
308/// Commands from the first subcommand through `cmd`, recovered from its stamped path.
309///
310/// SDK renderers recurse with only the current command, while outputs and exit codes inherit
311/// through every ancestor. Looking up the chain here keeps those renderers from silently
312/// skipping declarations on an intermediate command.
313fn 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            // A programmatically constructed command may not belong to `spec`. Preserve the
319            // old root-plus-command behavior in that case; parsed specs always take the path.
320            return vec![cmd];
321        };
322        chain.push(next);
323        current = next;
324    }
325    chain
326}
327
328// ---------------------------------------------------------------------------
329// Choice type collection with collision detection
330// ---------------------------------------------------------------------------
331
332/// Maps choice type definitions and provides collision-aware type name lookup.
333///
334/// When two commands have the same arg/flag name with different choices,
335/// the type name is prefixed with the command's PascalCase name to avoid collision.
336pub(crate) struct ChoiceTypeMap {
337    /// Resolved type name -> choice values
338    pub types: IndexMap<String, Vec<String>>,
339    /// (cmd_name, item_name) -> resolved type name
340    name_map: IndexMap<(String, String), String>,
341}
342
343impl ChoiceTypeMap {
344    /// Look up the resolved type name for a choice arg/flag.
345    /// `cmd_name` is the command's name (empty string for root).
346    /// `item_name` is the arg or flag name.
347    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    /// Iterate over type definitions (name, choices).
354    pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
355        self.types.iter()
356    }
357
358    /// Check if there are no choice types.
359    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
372/// Collects all unique choice types across a command tree.
373/// When two commands have the same arg/flag name with different choices,
374/// the type name is prefixed with the command's PascalCase name to avoid collision.
375pub(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    // Group by base type name, check for choice differences
380    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
463/// Collects type names that need to be imported from the types module.
464pub(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    // The alias a parsed output flows through, so a generated signature never names
518    // `unknown` directly and the follow-up that fills it in touches no call site.
519    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    /// Hidden command with choices — covers collect_choice_entries skip paths
569    /// and collect_type_imports_recursive cmd.hide path.
570    #[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        // hidden command's choices should not be collected
592        assert!(choice_types.lookup("hidden", "mode").is_none());
593        assert!(choice_types.lookup("hidden", "level").is_none());
594        // visible command's choices should be collected
595        assert!(choice_types.lookup("visible", "env").is_some());
596    }
597
598    /// Hidden arg/flag with choices — covers skip paths in collect_choice_entries.
599    #[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    /// Flag with arg choices — covers flag.arg choices import path.
626    #[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}