Skip to main content

usage/sdk/
mod.rs

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
46/// Escape JSDoc-terminating sequences in comment text.
47pub(crate) fn escape_jsdoc(s: &str) -> String {
48    s.replace("*/", r"*\/")
49}
50
51/// Escape triple-quote sequences and backslashes in Python docstrings.
52pub(crate) fn escape_py_docstring(s: &str) -> String {
53    s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
54}
55
56/// Escape a string for a double-quoted literal.
57///
58/// Backslashes, quotes, and control characters — the last of these because neither language
59/// can carry one literally inside a quoted string, so a value with a newline in it wrote a
60/// module that fails to import rather than one that says something wrong. Help text and
61/// config defaults both really do contain them.
62///
63/// Python and TypeScript spell all of these the same way, so one function serves both.
64fn 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
80/// Escape a string for a Python literal.
81pub(crate) fn escape_py_string(s: &str) -> String {
82    escape_string_literal(s)
83}
84
85/// Escape a string for a TypeScript literal.
86pub(crate) fn escape_ts_string(s: &str) -> String {
87    escape_string_literal(s)
88}
89
90/// A simple code writer with indentation management.
91pub(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    /// Create a CodeWriter with custom indent string (e.g. "    " for Python).
107    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
153/// Returns the PascalCase type name for a command: the command name, or the package name for root.
154pub(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
162/// A command type name qualified by its full path, for declarations emitted at module scope.
163pub(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
174// ---------------------------------------------------------------------------
175// Declared outputs, as generated client methods
176// ---------------------------------------------------------------------------
177
178/// One extra method on a generated command class, for one declared output.
179///
180/// `exec()` is never changed: a caller who wants raw text keeps getting it. What framing
181/// buys is a method whose *shape* matches the wire format, because `json` is read to the
182/// end and parsed once while `jsonl` arrives a line at a time and may never finish. Those
183/// are different signatures, not different parse calls.
184pub(crate) struct OutputMethod {
185    /// Appended to `exec`, so `exec_jsonl` / `execJsonl`.
186    pub suffix: String,
187    pub framing: Framing,
188    /// The words that pick this output, ready to append to an argv.
189    pub select: Vec<String>,
190    /// The selecting flag's property name on the flags bag, so a caller-supplied value of
191    /// it can be left out rather than duplicated on the command line.
192    pub omit: Option<String>,
193    /// The generated type alias a parsed value flows through.
194    pub type_alias: String,
195    /// The generated constant holding the schema, and the schema itself, where one was
196    /// declared.
197    pub schema_const: Option<String>,
198    pub schema: Option<String>,
199    pub help: Option<String>,
200}
201
202/// The methods a command's declared outputs earn it.
203///
204/// Named after the **framing**, not the output's own token. That is the whole point of the
205/// split: hk spells its line-delimited output `jsonl` and aube spells the identical format
206/// `ndjson`, so `exec_ndjson()` for one and `exec_jsonl()` for the other would put the
207/// per-CLI spelling back into every caller. The token goes into the argv this builds; the
208/// caller never types it.
209pub(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            // Several outputs can share a framing — a CLI with both `json` and
228            // `json-compact`. The default one keeps the plain name and the rest are
229            // suffixed, so the common call stays short and none of them collide.
230            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
260/// Whether a flag answers to a selector spelling, dashes or not.
261pub(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
268/// SHOUTY_SNAKE, for the generated constant names.
269pub(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
291/// The exit codes a generated client should document, folded from the spec's.
292pub(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
297/// Commands from the first subcommand through `cmd`, recovered from its stamped path.
298///
299/// SDK renderers recurse with only the current command, while outputs and exit codes inherit
300/// through every ancestor. Looking up the chain here keeps those renderers from silently
301/// skipping declarations on an intermediate command.
302fn 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            // A programmatically constructed command may not belong to `spec`. Preserve the
308            // old root-plus-command behavior in that case; parsed specs always take the path.
309            return vec![cmd];
310        };
311        chain.push(next);
312        current = next;
313    }
314    chain
315}
316
317// ---------------------------------------------------------------------------
318// Choice type collection with collision detection
319// ---------------------------------------------------------------------------
320
321/// Maps choice type definitions and provides collision-aware type name lookup.
322///
323/// When two commands have the same arg/flag name with different choices,
324/// the type name is prefixed with the command's PascalCase name to avoid collision.
325pub(crate) struct ChoiceTypeMap {
326    /// Resolved type name -> choice values
327    pub types: IndexMap<String, Vec<String>>,
328    /// (cmd_name, item_name) -> resolved type name
329    name_map: IndexMap<(String, String), String>,
330}
331
332impl ChoiceTypeMap {
333    /// Look up the resolved type name for a choice arg/flag.
334    /// `cmd_name` is the command's name (empty string for root).
335    /// `item_name` is the arg or flag name.
336    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    /// Iterate over type definitions (name, choices).
343    pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
344        self.types.iter()
345    }
346
347    /// Check if there are no choice types.
348    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
361/// Collects all unique choice types across a command tree.
362/// When two commands have the same arg/flag name with different choices,
363/// the type name is prefixed with the command's PascalCase name to avoid collision.
364pub(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    // Group by base type name, check for choice differences
369    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
452/// Collects type names that need to be imported from the types module.
453pub(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    // The alias a parsed output flows through, so a generated signature never names
508    // `unknown` directly and the follow-up that fills it in touches no call site.
509    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    /// Hidden command with choices — covers collect_choice_entries skip paths
559    /// and collect_type_imports_recursive cmd.hide path.
560    #[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        // hidden command's choices should not be collected
582        assert!(choice_types.lookup("hidden", "mode").is_none());
583        assert!(choice_types.lookup("hidden", "level").is_none());
584        // visible command's choices should be collected
585        assert!(choice_types.lookup("visible", "env").is_some());
586    }
587
588    /// Hidden arg/flag with choices — covers skip paths in collect_choice_entries.
589    #[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    /// Flag with arg choices — covers flag.arg choices import path.
616    #[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}