From 01c2f474a90d60993e9c302da009ee61b8f3c75b Mon Sep 17 00:00:00 2001 From: gaojunran Date: Sun, 3 May 2026 16:32:41 +0800 Subject: [PATCH 01/13] feat: generate SDK for TypeScript, Python and Rust --- cli/src/cli/generate/mod.rs | 3 + cli/src/cli/generate/sdk.rs | 67 ++ docs/.vitepress/config.mts | 1 + docs/cli/sdk.md | 212 +++++ docs/spec/index.md | 1 + lib/Cargo.toml | 3 +- lib/src/lib.rs | 2 + lib/src/sdk/mod.rs | 202 +++++ lib/src/sdk/python/mod.rs | 800 ++++++++++++++++++ lib/src/sdk/python/runtime.rs | 36 + ...ge__sdk__python__tests__python_client.snap | 68 ++ ...on__tests__python_full_feature_client.snap | 83 ++ ...hon__tests__python_full_feature_types.snap | 81 ++ ..._tests__python_hyphenated_subcommands.snap | 43 + ...sage__sdk__python__tests__python_init.snap | 6 + ...e__sdk__python__tests__python_minimal.snap | 16 + ...e__sdk__python__tests__python_runtime.snap | 39 + ...age__sdk__python__tests__python_types.snap | 52 ++ lib/src/sdk/rust/client.rs | 309 +++++++ lib/src/sdk/rust/mod.rs | 204 +++++ lib/src/sdk/rust/runtime.rs | 72 ++ .../usage__sdk__rust__tests__rust_client.snap | 123 +++ ...rust__tests__rust_full_feature_client.snap | 144 ++++ ..._rust__tests__rust_full_feature_types.snap | 126 +++ ...t__tests__rust_hyphenated_subcommands.snap | 75 ++ .../usage__sdk__rust__tests__rust_lib.snap | 13 + ...usage__sdk__rust__tests__rust_minimal.snap | 29 + ...usage__sdk__rust__tests__rust_runtime.snap | 75 ++ .../usage__sdk__rust__tests__rust_types.snap | 100 +++ lib/src/sdk/rust/types.rs | 372 ++++++++ lib/src/sdk/typescript/mod.rs | 43 + lib/src/sdk/typescript/runtime.rs | 43 + ...ypescript__types__tests__deep_nesting.snap | 102 +++ ...pt__types__tests__full_feature_client.snap | 99 +++ ...ipt__types__tests__full_feature_types.snap | 64 ++ ..._types__tests__hyphenated_subcommands.snap | 56 ++ ...ypescript__types__tests__minimal_spec.snap | 17 + ...__types__tests__package_name_override.snap | 6 + ...ript__types__tests__typescript_client.snap | 81 ++ ...cript__types__tests__typescript_index.snap | 6 + ...ipt__types__tests__typescript_runtime.snap | 46 + ...cript__types__tests__typescript_types.snap | 42 + lib/src/sdk/typescript/types.rs | 501 +++++++++++ lib/src/sdk/typescript/wrappers.rs | 336 ++++++++ lib/src/spec/mod.rs | 2 +- 45 files changed, 4799 insertions(+), 2 deletions(-) create mode 100644 cli/src/cli/generate/sdk.rs create mode 100644 docs/cli/sdk.md create mode 100644 lib/src/sdk/mod.rs create mode 100644 lib/src/sdk/python/mod.rs create mode 100644 lib/src/sdk/python/runtime.rs create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_client.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_client.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_types.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_hyphenated_subcommands.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_init.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_minimal.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_runtime.snap create mode 100644 lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_types.snap create mode 100644 lib/src/sdk/rust/client.rs create mode 100644 lib/src/sdk/rust/mod.rs create mode 100644 lib/src/sdk/rust/runtime.rs create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_client.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_client.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_types.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_hyphenated_subcommands.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_lib.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_minimal.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_runtime.snap create mode 100644 lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_types.snap create mode 100644 lib/src/sdk/rust/types.rs create mode 100644 lib/src/sdk/typescript/mod.rs create mode 100644 lib/src/sdk/typescript/runtime.rs create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__deep_nesting.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_client.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_types.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__hyphenated_subcommands.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__minimal_spec.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__package_name_override.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_client.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_index.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_runtime.snap create mode 100644 lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_types.snap create mode 100644 lib/src/sdk/typescript/types.rs create mode 100644 lib/src/sdk/typescript/wrappers.rs diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 6881df8b..8a39e40f 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -9,6 +9,7 @@ mod fig; mod json; mod manpage; mod markdown; +mod sdk; /// Generate completions, documentation, and other artifacts from usage specs #[derive(clap::Args)] @@ -25,6 +26,7 @@ pub enum Command { Json(json::Json), Manpage(manpage::Manpage), Markdown(markdown::Markdown), + Sdk(sdk::Sdk), } impl Generate { @@ -35,6 +37,7 @@ impl Generate { Command::Json(cmd) => cmd.run(), Command::Manpage(cmd) => cmd.run(), Command::Markdown(cmd) => cmd.run(), + Command::Sdk(cmd) => cmd.run(), } } } diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs new file mode 100644 index 00000000..35c1fa72 --- /dev/null +++ b/cli/src/cli/generate/sdk.rs @@ -0,0 +1,67 @@ +use std::path::PathBuf; + +use clap::Args; + +use crate::cli::generate; + +use usage::sdk::{SdkLanguage, SdkOptions}; + +#[derive(Args)] +#[clap(about = "Generate a type-safe SDK from a usage spec")] +pub struct Sdk { + /// Target language for the SDK + #[clap(short, long, value_parser = ["typescript", "python", "rust"])] + language: String, + + /// Output directory for generated SDK files + #[clap(short, long)] + output: PathBuf, + + /// Override the package/module name (defaults to spec bin name) + #[clap(short, long)] + package_name: Option, + + /// A usage spec taken in as a file + #[clap(short, long)] + file: Option, + + /// Raw string spec input + #[clap(long, required_unless_present = "file", overrides_with = "file")] + spec: Option, +} + +impl Sdk { + pub fn run(&self) -> miette::Result<()> { + let spec = generate::file_or_spec(&self.file, &self.spec)?; + + let language = match self.language.as_str() { + "typescript" => SdkLanguage::TypeScript, + "python" => SdkLanguage::Python, + "rust" => SdkLanguage::Rust, + other => { + return Err(miette::miette!("unsupported language: {other}")); + } + }; + + let source_file = self.file.as_ref().map(|p| p.display().to_string()); + + let opts = SdkOptions { + language, + package_name: self.package_name.clone(), + source_file, + }; + + let output = usage::sdk::generate(&spec, &opts); + + std::fs::create_dir_all(&self.output) + .map_err(|e| miette::miette!("failed to create output directory: {e}"))?; + + for file in &output.files { + let path = self.output.join(&file.path); + println!("writing to {}", path.display()); + xx::file::write(&path, &file.content)?; + } + + Ok(()) + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5beb1540..0b807e4e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -57,6 +57,7 @@ export default defineConfig({ { text: "Completions", link: "/cli/completions" }, { text: "Manpages", link: "/cli/manpages" }, { text: "Markdown", link: "/cli/markdown" }, + { text: "SDK Generation", link: "/cli/sdk" }, { text: "Scripts", link: "/cli/scripts" }, { text: "CLI Reference", link: "/cli/reference/", items: diff --git a/docs/cli/sdk.md b/docs/cli/sdk.md new file mode 100644 index 00000000..b06190d8 --- /dev/null +++ b/docs/cli/sdk.md @@ -0,0 +1,212 @@ +# Generating Type-Safe SDKs + +Usage CLI can generate type-safe SDK client libraries from a Usage spec. The generated SDK is a +**subprocess wrapper** -- it invokes your CLI binary via `subprocess.run` / `execFileSync` / +`std::process::Command`, not a native binding. It provides type definitions for arguments, flags, +and choices, along with a client that constructs the correct CLI argument list for you. + +## When to Use This + +### CLIs without language bindings + +Popular tools like ffmpeg have hand-written bindings in many languages, but the vast majority of CLI +tools don't. For tools like `restic`, `rclone`, `pandoc`, `age`, or any internal CLI, the only +option has been to manually construct argument lists: + +```python +# before: stringly-typed, no autocomplete, typos slip through +subprocess.run(["rclone", "copy", src, dst, "--progress", "--transfers", "4"]) +``` + +With a generated SDK: + +```python +# after: typed, autocomplete, mistakes caught at lint time +rclone.copy(src, dst, progress=True, transfers=4) +``` + +### Stay in sync with CLI versions + +Hand-written bindings drift out of date when the CLI evolves. Generated SDKs solve this the same way +Protobuf/gRPC does -- the spec is the source of truth, the SDK is a derived artifact: + +```sh +# in CI, when you cut a new release: +usage generate sdk -l python -o ./sdk/python/ -f ./mycli.usage.kdl +git commit -m "chore: regenerate sdk from v2.3.0 spec" +``` + +### Internal platform CLIs + +This is the strongest use case. Companies typically have internal CLIs for deployment, config +management, database migrations, etc. Teams in different languages (Python scripts, TypeScript +services, Rust tools) all need to call these CLIs, and each team independently writes fragile +subprocess calls. With a Usage spec, you generate typed SDKs for all languages from a single source +of truth: + +```ts +// auto-generated, always in sync with the CLI +import { deploy } from "@internal/platform-sdk"; +await deploy({ env: "prod", service: "api", replicas: 3 }); +// ^ typed, choices-constrained, required-checked +``` + +## Quick Start + +Given a spec file `mycli.usage.kdl`: + +```sh +usage generate sdk -l typescript -o ./sdk -f ./mycli.usage.kdl +``` + +This generates a complete SDK in the `./sdk` directory, ready to use: + +```ts +import { Mycli } from "./sdk"; + +const cli = new Mycli(); +const result = cli.build.exec( + { target: "release", output: "./dist" }, + { release: true } +); +if (result.ok) { + console.log(result.stdout); +} +``` + +## Supported Languages + +| Language | Flag | Output Files | +|----------|------|-------------| +| TypeScript | `-l typescript` | `types.ts`, `client.ts`, `runtime.ts`, `index.ts` | +| Python | `-l python` | `types.py`, `client.py`, `runtime.py`, `__init__.py` | +| Rust | `-l rust` | `src/types.rs`, `src/client.rs`, `src/runtime.rs`, `src/lib.rs`, `Cargo.toml` | + +### TypeScript + +```sh +usage generate sdk -l typescript -o ./sdk -f ./mycli.usage.kdl +``` + +Generates ES module files with full type annotations. The client uses `execFileSync` from +`node:child_process` under the hood. + +```ts +import { Mycli, BuildArgs, BuildFlags } from "./sdk"; + +const cli = new Mycli(); +const result = cli.build.exec( + { target: "release", output: "./dist" } as BuildArgs, + { release: true } as BuildFlags +); +``` + +### Python + +```sh +usage generate sdk -l python -o ./sdk -f ./mycli.usage.kdl +``` + +Generates a Python package with `@dataclass` type definitions and type annotations. The client uses +`subprocess.run` under the hood. + +```python +from sdk import Mycli, BuildArgs, BuildFlags + +cli = Mycli() +result = cli.build.exec( + BuildArgs(target="release", output="./dist"), + BuildFlags(release=True) +) +if result.ok: + print(result.stdout) +``` + +### Rust + +```sh +usage generate sdk -l rust -o ./sdk -f ./mycli.usage.kdl +``` + +Generates a zero-dependency Rust crate with idiomatic types: enums for choices, structs for +args/flags, and `Result` return types. The client uses +`std::process::Command` under the hood. + +```rust +use mycli_sdk::{Mycli, BuildArgs, BuildFlags, TargetChoice}; + +let cli = Mycli::new("mycli"); +let result = cli.build.exec( + BuildArgs { target: TargetChoice::Release, output: "./dist".into() }, + Some(&BuildFlags { release: Some(true), ..Default::default() }), +)?; +if result.ok() { + println!("{}", result.stdout); +} +``` + +## CLI Options + +``` +usage generate sdk [OPTIONS] + +Options: + -l, --language Target language: typescript, python, rust + -o, --output Output directory for generated SDK files + -p, --package-name Override the package/module name (defaults to spec bin name) + -f, --file A usage spec taken in as a file + --spec Raw string spec input +``` + +## Feature Support + +The following table shows which Usage spec features are supported by each language target: + +| Feature | Spec Syntax | TypeScript | Python | Rust | +|---------|-------------|:---------:|:------:|:----:| +| Positional args | `arg "name"` | ✅ | ✅ | ✅ | +| Required args | `arg "name" required=#true` | ✅ | ✅ | ✅ | +| Optional args | `arg "[name]"` | ✅ | ✅ | ✅ | +| Variadic args | `arg "name" var=#true` | ✅ | ✅ | ✅ | +| Arg choices | `arg "name" { choices "a" "b" }` | ✅ | ✅ | ✅ | +| Arg defaults | `arg "name" default="value"` | ✅ | ✅ | ✅ | +| Arg help text | `arg "name" help="..."` | ✅ | ✅ | ✅ | +| Arg env var | `arg "name" env="VAR"` | ✅ | ✅ | ✅ | +| Double dash | `arg "name" double_dash="required"` | ✅ | ✅ | ✅ | +| Boolean flags | `flag "--flag"` | ✅ | ✅ | ✅ | +| Value flags | `flag "--flag "` | ✅ | ✅ | ✅ | +| Short flags | `flag "-f --flag"` | ✅ | ✅ | ✅ | +| Flag choices | `flag "--flag" { choices "a" "b" }` | ✅ | ✅ | ✅ | +| Flag defaults | `flag "--flag" default="val"` | ✅ | ✅ | ✅ | +| Flag help text | `flag "--flag" help="..."` | ✅ | ✅ | ✅ | +| Flag env var | `flag "--flag" env="VAR"` | ✅ | ✅ | ✅ | +| Count flags | `flag "-v" count=#true` | ✅ | ✅ | ✅ | +| Negate flags | `flag "--flag" negate="--no-flag"` | ✅ | ✅ | ✅ | +| Repeatable flags | `flag "--flag" var=#true` | ✅ | ✅ | ✅ | +| Required flags | `flag "--flag" required=#true` | ✅ | ✅ | ✅ | +| Global flags | `flag "--flag" global=#true` | ✅ | ✅ | ✅ | +| Deprecated flags | `flag "--flag" deprecated="msg"` | ✅ | ✅ | ✅ | +| Hidden args/flags | `hide=#true` | ✅ | ✅ | ✅ | +| Subcommands | `cmd "name" { ... }` | ✅ | ✅ | ✅ | +| Nested subcommands | `cmd "a" { cmd "b" { ... } }` | ✅ | ✅ | ✅ | +| Subcommand aliases | `alias "name"` | ✅ | ✅ | ✅ | +| Hyphenated names | `cmd "add-remote"` | ✅ | ✅ | ✅ | +| Spec metadata | `version`, `about`, `author` | ✅ | ✅ | ✅ | +| Config | `config "key" { ... }` | ✅ | ✅ | ✅ | + +## How It Works + +Each generated SDK consists of three parts: + +1. **Types module** -- Type definitions for every command's args and flags. Choice constraints are + rendered as union types (TypeScript), `Literal` types (Python), or enums with `Display` (Rust). + Global flags are propagated to all subcommand flag types. + +2. **Client module** -- A nested class/struct hierarchy mirroring the subcommand tree. Each node has + an `exec()` method that constructs the CLI argument list and invokes the binary. Flag arguments + are built via a helper method that handles value flags, boolean flags, count flags, negate flags, + and repeatable flags. + +3. **Runtime module** -- A small, static module containing `CliResult` (stdout, stderr, exit code) + and `CliRunner` (the subprocess invocation logic). This module is identical across all SDKs + generated from the same language target. diff --git a/docs/spec/index.md b/docs/spec/index.md index 1c624eee..d8514062 100644 --- a/docs/spec/index.md +++ b/docs/spec/index.md @@ -8,6 +8,7 @@ for CLIs. Here are some potential reasons for defining your CLI with a Usage spe - Generate autocompletion scripts - Generate markdown documentation - Generate man pages +- Generate type-safe SDK client libraries for TypeScript, Python, and Rust - Use an advanced arg parser in any language - Scaffold one spec into different CLI frameworks—even different languages - [coming soon] Host your CLI documentation on usage.sh diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a698bc5c..cfc7e0bb 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -40,9 +40,10 @@ versions = "7" xx = "2" [features] -default = ["docs"] +default = ["docs", "sdk"] docs = ["tera", "roff"] unstable_choices_env = [] +sdk = [] [dev-dependencies] criterion = "0.8" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index dbda03b2..8bb0bf23 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -23,6 +23,8 @@ pub use error::Result; #[cfg(feature = "docs")] pub mod docs; +#[cfg(feature = "sdk")] +pub mod sdk; pub mod parse; pub(crate) mod sh; pub(crate) mod string; diff --git a/lib/src/sdk/mod.rs b/lib/src/sdk/mod.rs new file mode 100644 index 00000000..1843d0a9 --- /dev/null +++ b/lib/src/sdk/mod.rs @@ -0,0 +1,202 @@ +use std::path::PathBuf; + +use heck::AsPascalCase; +use indexmap::IndexMap; + +use crate::spec::cmd::SpecCommand; +use crate::Spec; + +pub mod python; +pub mod rust; +pub mod typescript; + +#[derive(Debug, Clone)] +pub enum SdkLanguage { + TypeScript, + Python, + Rust, +} + +#[derive(Debug, Clone)] +pub struct SdkOptions { + pub language: SdkLanguage, + pub package_name: Option, + pub source_file: Option, +} + +#[derive(Debug)] +pub struct SdkOutput { + pub files: Vec, +} + +#[derive(Debug)] +pub struct SdkFile { + pub path: PathBuf, + pub content: String, +} + +pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput { + match opts.language { + SdkLanguage::TypeScript => typescript::generate(spec, opts), + SdkLanguage::Python => python::generate(spec, opts), + SdkLanguage::Rust => rust::generate(spec, opts), + } +} + +/// A simple code writer with indentation management. +pub(crate) struct CodeWriter { + buf: String, + indent: usize, + indent_str: &'static str, +} + +impl CodeWriter { + pub fn new() -> Self { + Self { + buf: String::new(), + indent: 0, + indent_str: " ", + } + } + + /// Create a CodeWriter with custom indent string (e.g. " " for Python). + pub fn with_indent(indent_str: &'static str) -> Self { + Self { + buf: String::new(), + indent: 0, + indent_str, + } + } + + pub fn line(&mut self, s: &str) { + if !s.is_empty() { + for _ in 0..self.indent { + self.buf.push_str(self.indent_str); + } + } + self.buf.push_str(s); + self.buf.push('\n'); + } + + pub fn indent(&mut self) { + self.indent += 1; + } + + pub fn dedent(&mut self) { + self.indent = self.indent.saturating_sub(1); + } + + pub fn to_string(self) -> String { + self.buf + } +} + +pub(crate) fn generated_header(comment_prefix: &str, source: &Option) -> String { + match source { + Some(s) => { + format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.") + } + None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."), + } +} + +/// Returns the PascalCase type name for a command: the command name, or the package name for root. +pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String { + if cmd.name.is_empty() { + AsPascalCase(package_name).to_string() + } else { + AsPascalCase(&cmd.name).to_string() + } +} + +/// Collects all unique choice types across a command tree. +pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> IndexMap> { + let mut types = IndexMap::new(); + collect_choice_types_recursive(cmd, &mut types); + types +} + +fn collect_choice_types_recursive(cmd: &SpecCommand, types: &mut IndexMap>) { + if cmd.hide { + return; + } + + for arg in &cmd.args { + if arg.hide { + continue; + } + if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&arg.name)); + types + .entry(type_name) + .or_insert_with(|| choices.choices.clone()); + } + } + + for flag in &cmd.flags { + if flag.hide { + continue; + } + if let Some(arg) = &flag.arg { + if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&flag.name)); + types + .entry(type_name) + .or_insert_with(|| choices.choices.clone()); + } + } + } + + for subcmd in cmd.subcommands.values() { + collect_choice_types_recursive(subcmd, types); + } +} + +/// Collects type names that need to be imported from the types module. +pub(crate) fn collect_type_imports(cmd: &SpecCommand, package_name: &str) -> Vec { + let mut imports = Vec::new(); + collect_type_imports_recursive(cmd, package_name, &mut imports); + imports.sort(); + imports.dedup(); + imports +} + +fn collect_type_imports_recursive( + cmd: &SpecCommand, + package_name: &str, + imports: &mut Vec, +) { + if cmd.hide { + return; + } + + let name = command_type_name(cmd, package_name); + let has_args = cmd.args.iter().any(|a| !a.hide); + let has_flags = cmd.flags.iter().any(|f| !f.hide); + + if has_args { + imports.push(format!("{name}Args")); + } + if has_flags { + imports.push(format!("{name}Flags")); + } + + for arg in &cmd.args { + if !arg.hide && arg.choices.is_some() { + imports.push(format!("{}Choice", AsPascalCase(&arg.name))); + } + } + for flag in &cmd.flags { + if !flag.hide { + if let Some(arg) = &flag.arg { + if arg.choices.is_some() { + imports.push(format!("{}Choice", AsPascalCase(&flag.name))); + } + } + } + } + + for subcmd in cmd.subcommands.values() { + collect_type_imports_recursive(subcmd, package_name, imports); + } +} diff --git a/lib/src/sdk/python/mod.rs b/lib/src/sdk/python/mod.rs new file mode 100644 index 00000000..130ec1a5 --- /dev/null +++ b/lib/src/sdk/python/mod.rs @@ -0,0 +1,800 @@ +use std::path::PathBuf; + +use heck::AsPascalCase; +use indexmap::IndexMap; + +use crate::sdk::{collect_choice_types, collect_type_imports, command_type_name, generated_header, CodeWriter, SdkFile, SdkOptions, SdkOutput}; +use crate::spec::arg::SpecDoubleDashChoices; +use crate::spec::cmd::SpecCommand; +use crate::spec::config::SpecConfigProp; +use crate::spec::data_types::SpecDataTypes; +use crate::{Spec, SpecArg, SpecFlag}; + +mod runtime; + +pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput { + let package_name = opts + .package_name + .clone() + .unwrap_or_else(|| spec.bin.clone()); + + SdkOutput { + files: vec![ + SdkFile { + path: PathBuf::from("types.py"), + content: render_types(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("client.py"), + content: render_client(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("runtime.py"), + content: runtime::RUNTIME_PY.to_string(), + }, + SdkFile { + path: PathBuf::from("__init__.py"), + content: render_init(&package_name), + }, + ], + } +} + +fn render_init(package_name: &str) -> String { + let class_name = AsPascalCase(package_name).to_string(); + format!("from .client import {class_name}\nfrom .types import *\n") +} + +// --------------------------------------------------------------------------- +// types.py +// --------------------------------------------------------------------------- + +fn render_types(spec: &Spec, package_name: &str, source_file: &Option) -> String { + let mut w = CodeWriter::with_indent(" "); + + w.line(&generated_header("#", source_file)); + w.line("from __future__ import annotations"); + w.line("from dataclasses import dataclass"); + w.line("from typing import Literal, Optional, Union"); + w.line(""); + + // spec metadata + if let Some(version) = &spec.version { + w.line(&format!("VERSION = \"{version}\"")); + } + if let Some(about) = &spec.about { + w.line(&format!("ABOUT = \"{about}\"")); + } + if let Some(author) = &spec.author { + w.line(&format!("AUTHOR = \"{author}\"")); + } + + let choice_types = collect_choice_types(&spec.cmd); + let root_global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + let has_global_flags = !root_global_flags.is_empty(); + + if !choice_types.is_empty() { + w.line(""); + for (name, choices) in &choice_types { + let union = choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + w.line(&format!("{name} = Literal[{union}]")); + } + } + + if has_global_flags { + w.line(""); + render_flags_dataclass("GlobalFlags", &root_global_flags, &choice_types, &mut w); + } + + render_command_types( + &spec.cmd, + package_name, + &choice_types, + has_global_flags, + &root_global_flags, + &mut w, + ); + + if !spec.config.props.is_empty() { + w.line(""); + let config_name = format!("{}Config", AsPascalCase(package_name)); + w.line(""); + w.line("@dataclass"); + w.line(&format!("class {config_name}:")); + w.indent(); + for (name, prop) in &spec.config.props { + let py_type = config_prop_type(prop); + let default = if prop.default.is_some() { + let d = prop.default.as_ref().unwrap(); + match prop.data_type { + SpecDataTypes::Boolean => format!(" = {d}"), + SpecDataTypes::Integer | SpecDataTypes::Float => format!(" = {d}"), + _ => format!(" = \"{d}\""), + } + } else { + String::new() + }; + if let Some(help) = &prop.help { + w.line(&format!("# {help}")); + } + w.line(&format!("{name}: {py_type}{default}")); + } + w.dedent(); + } + + w.to_string() +} + +fn render_command_types( + cmd: &SpecCommand, + package_name: &str, + choice_types: &IndexMap>, + has_global_flags: bool, + global_flags: &[&SpecFlag], + w: &mut CodeWriter, +) { + if cmd.hide { + return; + } + + let name = command_type_name(cmd, package_name); + let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_any_flags = !visible_flags.is_empty() || has_global_flags; + + if !visible_args.is_empty() { + w.line(""); + render_args_dataclass(&format!("{name}Args"), &visible_args, choice_types, w); + } + + if has_any_flags { + w.line(""); + let all_flags: Vec<&SpecFlag> = if has_global_flags { + global_flags + .iter() + .copied() + .chain(visible_flags.iter().filter(|f| !global_flags.iter().any(|gf| gf.name == f.name)).copied()) + .collect() + } else { + visible_flags + }; + render_flags_dataclass(&format!("{name}Flags"), &all_flags, choice_types, w); + } + + for subcmd in cmd.subcommands.values() { + render_command_types(subcmd, package_name, choice_types, has_global_flags, global_flags, w); + } +} + +fn render_args_dataclass( + name: &str, + args: &[&SpecArg], + choice_types: &IndexMap>, + w: &mut CodeWriter, +) { + w.line(""); + w.line(&format!("@dataclass")); + w.line(&format!("class {name}:")); + w.indent(); + if args.is_empty() { + w.line("pass"); + } else { + for arg in args { + let py_type = arg_py_type(arg, choice_types); + let optional = !(arg.required && arg.default.is_none()); + let field = if optional { + format!( + "{}: Optional[{}] = None", + sanitize_py_ident(&arg.name), + py_type + ) + } else if let Some(default) = &arg.default { + format!( + "{}: {} = \"{default}\"", + sanitize_py_ident(&arg.name), + py_type + ) + } else { + format!("{}: {}", sanitize_py_ident(&arg.name), py_type) + }; + if let Some(help) = &arg.help { + w.line(&format!("# {help}")); + } + w.line(&field); + } + } + w.dedent(); +} + +fn render_flags_dataclass( + name: &str, + flags: &[&SpecFlag], + choice_types: &IndexMap>, + w: &mut CodeWriter, +) { + w.line(""); + w.line(&format!("@dataclass")); + w.line(&format!("class {name}:")); + w.indent(); + if flags.is_empty() { + w.line("pass"); + } else { + for flag in flags { + let py_type = flag_py_type(flag, choice_types); + let prop_name = flag_property_name_py(flag); + let optional = !(flag.required && flag.default.is_none()); + let field = if let Some(default) = &flag.default { + // has explicit default + if flag.count { + format!("{prop_name}: {py_type} = {default}") + } else if flag.arg.is_none() { + // boolean with default + let val = match default.as_str() { + "true" | "#true" => "True", + "false" | "#false" => "False", + other => other, + }; + format!("{prop_name}: {py_type} = {val}") + } else { + format!("{prop_name}: Optional[{py_type}] = \"{default}\"") + } + } else if optional { + format!("{prop_name}: Optional[{py_type}] = None") + } else { + format!("{prop_name}: {py_type}") + }; + let mut doc_parts = Vec::new(); + if let Some(help) = &flag.help { + doc_parts.push(help.clone()); + } + if let Some(env) = &flag.env { + doc_parts.push(format!("Env: {env}")); + } + if let Some(deprecated) = &flag.deprecated { + doc_parts.push(format!("Deprecated: {deprecated}")); + } + if flag.long.len() > 1 { + let aliases: Vec<&str> = flag.long.iter().skip(1).map(|s| s.as_str()).collect(); + doc_parts.push(format!("Aliases: {}", aliases.join(", "))); + } + if !flag.short.is_empty() { + let shorts: Vec = flag.short.iter().map(|c| format!("-{c}")).collect(); + doc_parts.push(format!("Short: {}", shorts.join(", "))); + } + if !doc_parts.is_empty() { + w.line(&format!("# {}", doc_parts.join(". "))); + } + w.line(&field); + } + } + w.dedent(); +} + +fn arg_py_type(arg: &SpecArg, choice_types: &IndexMap>) -> String { + let base = if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&arg.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + let union = choices + .choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + format!("Literal[{union}]") + } + } else { + "str".to_string() + }; + + if arg.var { + format!("list[{base}]") + } else { + base + } +} + +fn flag_py_type(flag: &SpecFlag, choice_types: &IndexMap>) -> String { + if flag.count { + return "int".to_string(); + } + + match &flag.arg { + Some(arg) => { + let base = if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&flag.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + let union = choices + .choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + format!("Literal[{union}]") + } + } else { + "str".to_string() + }; + + if flag.var { + format!("list[{base}]") + } else { + base + } + } + None => "bool".to_string(), + } +} + +fn config_prop_type(prop: &SpecConfigProp) -> String { + match prop.data_type { + SpecDataTypes::String => "str".to_string(), + SpecDataTypes::Integer => "int".to_string(), + SpecDataTypes::Float => "float".to_string(), + SpecDataTypes::Boolean => "bool".to_string(), + SpecDataTypes::Null => "object".to_string(), + } +} + +fn flag_property_name_py(flag: &SpecFlag) -> String { + // Python uses snake_case for attributes + if let Some(long) = flag.long.first() { + return sanitize_py_ident(&heck::AsSnakeCase(long).to_string()); + } + if let Some(short) = flag.short.first() { + return short.to_string(); + } + sanitize_py_ident(&flag.name) +} + +fn sanitize_py_ident(name: &str) -> String { + let snake = heck::AsSnakeCase(name).to_string(); + match snake.as_str() { + "class" | "def" | "return" | "import" | "from" | "global" | "lambda" | "pass" | "raise" + | "with" | "yield" | "del" | "try" | "except" | "finally" | "while" | "for" | "if" + | "elif" | "else" | "and" | "or" | "not" | "in" | "is" | "as" | "break" | "continue" + | "assert" | "type" | "input" | "id" | "list" | "dict" | "set" | "print" | "range" + | "format" | "help" | "vars" | "dir" | "exec" | "exit" | "quit" | "bool" | "int" + | "str" | "float" | "bytes" | "object" | "super" | "property" | "static" | "True" + | "False" | "None" => format!("_{snake}"), + _ => snake, + } +} + +// --------------------------------------------------------------------------- +// client.py +// --------------------------------------------------------------------------- + +fn render_client(spec: &Spec, package_name: &str, source_file: &Option) -> String { + let mut w = CodeWriter::with_indent(" "); + + w.line(&generated_header("#", source_file)); + w.line("from __future__ import annotations"); + w.line("from typing import Optional"); + w.line("from .runtime import CliResult, CliRunner"); + + // collect imports from types + let type_imports = collect_type_imports(&spec.cmd, package_name); + let has_global_flags = spec.cmd.flags.iter().any(|f| f.global && !f.hide); + let mut all_imports = type_imports; + if has_global_flags { + all_imports.push("GlobalFlags".to_string()); + } + all_imports.sort(); + all_imports.dedup(); + if !all_imports.is_empty() { + w.line(&format!("from .types import {}", all_imports.join(", "))); + } + + w.line(""); + + let global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + + let class_name = AsPascalCase(package_name).to_string(); + render_class( + &spec.cmd, + &class_name, + true, + &global_flags, + &spec.bin, + &mut w, + ); + + w.to_string() +} + +fn render_class( + cmd: &SpecCommand, + class_name: &str, + is_root: bool, + global_flags: &[&SpecFlag], + bin_name: &str, + w: &mut CodeWriter, +) { + let visible_subcmds: Vec<_> = cmd.subcommands.iter().filter(|(_, c)| !c.hide).collect(); + + let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_args = !visible_args.is_empty(); + let has_flags = !visible_flags.is_empty() || !global_flags.is_empty(); + + // docstring on class + let mut class_doc = Vec::new(); + if let Some(help) = &cmd.help { + class_doc.push(help.clone()); + } else if let Some(about) = &cmd.help_long { + class_doc.push(about.clone()); + } + if let Some(deprecated) = &cmd.deprecated { + class_doc.push(format!("DEPRECATED: {deprecated}")); + } + if !cmd.aliases.is_empty() { + class_doc.push(format!("Aliases: {}", cmd.aliases.join(", "))); + } + + w.line(&format!("class {class_name}:")); + w.indent(); + + if !class_doc.is_empty() { + w.line(&format!("\"\"\"{}\"\"\"", class_doc.join(". "))); + } + + // constructor + if is_root { + w.line(&format!( + "def __init__(self, bin_path: str = \"{bin_name}\") -> None:" + )); + } else { + w.line("def __init__(self, runner: CliRunner) -> None:"); + } + w.indent(); + if is_root { + w.line("self._runner = CliRunner(bin_path)"); + } else { + w.line("self._runner = runner"); + } + for (name, _) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + let prop = sanitize_py_ident(name); + w.line(&format!("self.{prop} = {sub_class}(self._runner)")); + } + w.dedent(); + + // exec method + let args_param = if has_args { + format!("args: {class_name}Args") + } else { + String::new() + }; + let flags_type = if !global_flags.is_empty() && !visible_flags.is_empty() { + format!("{class_name}Flags") + } else if !global_flags.is_empty() && visible_flags.is_empty() { + "GlobalFlags".to_string() + } else if !visible_flags.is_empty() { + format!("{class_name}Flags") + } else { + String::new() + }; + let flags_param = if !flags_type.is_empty() { + format!(", flags: Optional[{flags_type}] = None") + } else { + String::new() + }; + let sig = if args_param.is_empty() && flags_param.is_empty() { + "def exec(self) -> CliResult:".to_string() + } else { + format!("def exec(self, {args_param}{flags_param}) -> CliResult:") + }; + + // docstring on exec + let mut exec_doc = Vec::new(); + if !cmd.usage.is_empty() { + exec_doc.push(cmd.usage.clone()); + } + for example in &cmd.examples { + let label = example.header.as_deref().unwrap_or("Example"); + exec_doc.push(format!("{label}: {code}", code = example.code)); + } + + w.line(""); + if !exec_doc.is_empty() { + w.line(&sig); + w.indent(); + w.line(&format!("\"\"\"{}\"\"\"", exec_doc.join("\\n"))); + } else { + w.line(&sig); + w.indent(); + } + + let path: String = cmd + .full_cmd + .iter() + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(", "); + w.line(&format!("cmd_args: list[str] = [{path}]")); + + if has_args { + let has_required_double_dash = visible_args + .iter() + .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Required)); + let has_automatic_double_dash = visible_args + .iter() + .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Automatic)); + + for arg in &visible_args { + let ident = sanitize_py_ident(&arg.name); + if arg.var { + w.line(&format!( + "if args.{ident} is not None: cmd_args.extend(args.{ident})" + )); + } else { + w.line(&format!( + "if args.{ident} is not None: cmd_args.append(str(args.{ident}))" + )); + } + } + + if has_required_double_dash { + w.line("cmd_args.append(\"--\")"); + } else if has_automatic_double_dash { + w.line("# double_dash=automatic: \"--\" is implied after the first positional arg"); + } + } + + if has_flags { + w.line("flag_args = self._build_flag_args(flags)"); + w.line("return self._runner.run(cmd_args + flag_args)"); + } else { + w.line("return self._runner.run(cmd_args)"); + } + + w.dedent(); + + // _build_flag_args + if has_flags { + w.line(""); + w.line(&format!( + "def _build_flag_args(self, flags: Optional[{flags_type}]) -> list[str]:" + )); + w.indent(); + w.line("result: list[str] = []"); + w.line("if flags is None: return result"); + + for flag in global_flags { + render_flag_build_py(flag, w); + } + for flag in &visible_flags { + // skip global flags already rendered above + if !global_flags.iter().any(|gf| gf.name == flag.name) { + render_flag_build_py(flag, w); + } + } + + w.line("return result"); + w.dedent(); + } + + // alias properties for subcommand aliases + for (name, subcmd) in &visible_subcmds { + for alias in &subcmd.aliases { + let alias_prop = sanitize_py_ident(alias); + let target_prop = sanitize_py_ident(name); + let sub_class = AsPascalCase(name).to_string(); + w.line(""); + w.line("@property"); + w.line(&format!("def {alias_prop}(self) -> {sub_class}:")); + w.indent(); + w.line(&format!("\"\"\"Alias for {name}.\"\"\"")); + w.line(&format!("return self.{target_prop}")); + w.dedent(); + } + } + + w.dedent(); // end class + + // render subcommand classes + for (name, subcmd) in &visible_subcmds { + w.line(""); + let sub_class = AsPascalCase(name).to_string(); + render_class(subcmd, &sub_class, false, global_flags, bin_name, w); + } +} + +fn render_flag_build_py(flag: &SpecFlag, w: &mut CodeWriter) { + let prop_name = flag_property_name_py(flag); + let flag_arg_name = if let Some(long) = flag.long.first() { + format!("--{long}") + } else if let Some(short) = flag.short.first() { + format!("-{short}") + } else { + format!("--{}", flag.name) + }; + + if flag.arg.is_some() { + if flag.var { + w.line(&format!("if flags.{prop_name} is not None:")); + w.indent(); + w.line(&format!( + "for v in flags.{prop_name}: result.extend([\"{flag_arg_name}\", str(v)])" + )); + w.dedent(); + } else { + w.line(&format!( + "if flags.{prop_name} is not None: result.extend([\"{flag_arg_name}\", str(flags.{prop_name})])" + )); + } + } else if flag.count { + w.line(&format!( + "if flags.{prop_name} is not None and flags.{prop_name} > 0: result.extend([\"{flag_arg_name}\"] * flags.{prop_name})" + )); + } else if flag.var { + w.line(&format!("if flags.{prop_name} is not None:")); + w.indent(); + w.line(&format!("for v in flags.{prop_name}:")); + w.indent(); + w.line(&format!("if v: result.append(\"{flag_arg_name}\")")); + w.dedent(); + w.dedent(); + } else { + w.line(&format!( + "if flags.{prop_name}: result.append(\"{flag_arg_name}\")" + )); + if let Some(negate) = &flag.negate { + w.line(&format!( + "elif flags.{prop_name} is False: result.append(\"{negate}\")" + )); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use crate::sdk::{SdkLanguage, SdkOptions}; + use crate::test::SPEC_KITCHEN_SINK; + use crate::Spec; + + fn make_opts() -> SdkOptions { + SdkOptions { + language: SdkLanguage::Python, + package_name: None, + source_file: Some("test.usage.kdl".to_string()), + } + } + + fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str { + output + .files + .iter() + .find(|f| f.path.to_str() == Some(name)) + .unwrap_or_else(|| panic!("{name} should exist")) + .content + .as_str() + } + + #[test] + fn test_python_types() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "types.py")); + } + + #[test] + fn test_python_client() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.py")); + } + + #[test] + fn test_python_runtime() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "runtime.py")); + } + + #[test] + fn test_python_init() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "__init__.py")); + } + + fn full_feature_spec() -> Spec { + r##" + bin "mytool" + name "mytool" + version "1.2.3" + about "A powerful CLI tool" + author "Jane Doe" + + flag "-v --verbose" help="Verbosity level" count=#true global=#true + flag "-C --config " help="Config file path" global=#true env="MYTOOL_CONFIG" + flag "--dry-run" help="Show what would be done" negate="--no-dry-run" + + arg "input" help="Input file" required=#true + arg "extra" var=#true help="Extra files" + + cmd "build" help="Build the project" deprecated="Use compile instead" { + alias "b" + arg "target" help="Build target" { + choices "debug" "release" + } + arg "output" help="Output directory" double_dash="required" + flag "-j --jobs " help="Parallel jobs" var=#true + flag "--release" help="Build in release mode" + } + + cmd "deploy" help="Deploy the project" { + arg "env" help="Target environment" { + choices "staging" "production" + } + arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5 + flag "-f --force" help="Force deploy" deprecated="Use --confirm instead" + flag "--confirm" help="Confirm deployment" + } + "## + .parse() + .unwrap() + } + + #[test] + fn test_python_full_feature_types() { + let spec = full_feature_spec(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "types.py")); + } + + #[test] + fn test_python_full_feature_client() { + let spec = full_feature_spec(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.py")); + } + + #[test] + fn test_python_hyphenated_subcommands() { + let spec: Spec = r##" + bin "cli" + cmd "add-remote" help="Add a remote" { + arg "name" + arg "url" + } + cmd "remove-remote" help="Remove a remote" { + arg "name" + } + "## + .parse() + .unwrap(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.py")); + } + + #[test] + fn test_python_minimal() { + let spec: Spec = r##" + bin "hello" + "## + .parse() + .unwrap(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.py")); + } +} diff --git a/lib/src/sdk/python/runtime.rs b/lib/src/sdk/python/runtime.rs new file mode 100644 index 00000000..2641ec86 --- /dev/null +++ b/lib/src/sdk/python/runtime.rs @@ -0,0 +1,36 @@ +pub const RUNTIME_PY: &str = r#"# Runtime module for usage-generated SDK clients. Do not edit manually. +from __future__ import annotations + +import subprocess + + +class CliResult: + """Result of a CLI invocation.""" + + def __init__(self, stdout: str, stderr: str, exit_code: int) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + @property + def ok(self) -> bool: + return self.exit_code == 0 + + +class CliRunner: + """Runs a CLI binary via subprocess.""" + + def __init__(self, bin_path: str) -> None: + self.bin_path = bin_path + + def run(self, args: list[str]) -> CliResult: + try: + result = subprocess.run( + [self.bin_path, *args], + capture_output=True, + text=True, + ) + return CliResult(result.stdout, result.stderr, result.returncode) + except FileNotFoundError: + raise RuntimeError(f"CLI binary not found: {self.bin_path}") +"#; diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_client.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_client.snap new file mode 100644 index 00000000..6e28e99a --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_client.snap @@ -0,0 +1,68 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"client.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from typing import Optional +from .runtime import CliResult, CliRunner +from .types import Arg2Choice, InstallArgs, InstallFlags, MycliArgs, MycliFlags, ShellChoice + +class Mycli: + def __init__(self, bin_path: str = "mycli") -> None: + self._runner = CliRunner(bin_path) + self.plugin = Plugin(self._runner) + + def exec(self, args: MycliArgs, flags: Optional[MycliFlags] = None) -> CliResult: + """[FLAGS] """ + cmd_args: list[str] = [] + if args.arg1 is not None: cmd_args.append(str(args.arg1)) + if args.arg2 is not None: cmd_args.append(str(args.arg2)) + if args.arg3 is not None: cmd_args.append(str(args.arg3)) + if args.argrest is not None: cmd_args.extend(args.argrest) + if args.with_default is not None: cmd_args.append(str(args.with_default)) + flag_args = self._build_flag_args(flags) + return self._runner.run(cmd_args + flag_args) + + def _build_flag_args(self, flags: Optional[MycliFlags]) -> list[str]: + result: list[str] = [] + if flags is None: return result + if flags.flag1: result.append("--flag1") + if flags.flag2: result.append("--flag2") + if flags.flag3: result.append("--flag3") + elif flags.flag3 is False: result.append("--no-flag3") + if flags.with_default: result.append("--with-default") + if flags.shell is not None: result.extend(["--shell", str(flags.shell)]) + return result + +class Plugin: + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + self.install = Install(self._runner) + + def exec(self) -> CliResult: + """plugin """ + cmd_args: list[str] = ["plugin"] + return self._runner.run(cmd_args) + +class Install: + """install a plugin""" + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + + def exec(self, args: InstallArgs, flags: Optional[InstallFlags] = None) -> CliResult: + """plugin install [FLAGS] """ + cmd_args: list[str] = ["plugin", "install"] + if args.plugin is not None: cmd_args.append(str(args.plugin)) + if args.version is not None: cmd_args.append(str(args.version)) + flag_args = self._build_flag_args(flags) + return self._runner.run(cmd_args + flag_args) + + def _build_flag_args(self, flags: Optional[InstallFlags]) -> list[str]: + result: list[str] = [] + if flags is None: return result + if flags._global: result.append("--global") + if flags._dir is not None: result.extend(["--dir", str(flags._dir)]) + if flags.force: result.append("--force") + elif flags.force is False: result.append("--no-force") + return result diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_client.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_client.snap new file mode 100644 index 00000000..9dd36c0f --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_client.snap @@ -0,0 +1,83 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"client.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from typing import Optional +from .runtime import CliResult, CliRunner +from .types import BuildArgs, BuildFlags, DeployArgs, DeployFlags, EnvChoice, GlobalFlags, MytoolArgs, MytoolFlags, TargetChoice + +class Mytool: + def __init__(self, bin_path: str = "mytool") -> None: + self._runner = CliRunner(bin_path) + self.build = Build(self._runner) + self.deploy = Deploy(self._runner) + + def exec(self, args: MytoolArgs, flags: Optional[MytoolFlags] = None) -> CliResult: + """[FLAGS] """ + cmd_args: list[str] = [] + if args._input is not None: cmd_args.append(str(args._input)) + if args.extra is not None: cmd_args.extend(args.extra) + flag_args = self._build_flag_args(flags) + return self._runner.run(cmd_args + flag_args) + + def _build_flag_args(self, flags: Optional[MytoolFlags]) -> list[str]: + result: list[str] = [] + if flags is None: return result + if flags.verbose is not None and flags.verbose > 0: result.extend(["--verbose"] * flags.verbose) + if flags.config is not None: result.extend(["--config", str(flags.config)]) + if flags.dry_run: result.append("--dry-run") + elif flags.dry_run is False: result.append("--no-dry-run") + return result + + @property + def b(self) -> Build: + """Alias for build.""" + return self.build + +class Build: + """Build the project. DEPRECATED: Use compile instead. Aliases: b""" + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + + def exec(self, args: BuildArgs, flags: Optional[BuildFlags] = None) -> CliResult: + """build [-j --jobs… ] [--release] <-- output>""" + cmd_args: list[str] = ["build"] + if args.target is not None: cmd_args.append(str(args.target)) + if args.output is not None: cmd_args.append(str(args.output)) + cmd_args.append("--") + flag_args = self._build_flag_args(flags) + return self._runner.run(cmd_args + flag_args) + + def _build_flag_args(self, flags: Optional[BuildFlags]) -> list[str]: + result: list[str] = [] + if flags is None: return result + if flags.verbose is not None and flags.verbose > 0: result.extend(["--verbose"] * flags.verbose) + if flags.config is not None: result.extend(["--config", str(flags.config)]) + if flags.jobs is not None: + for v in flags.jobs: result.extend(["--jobs", str(v)]) + if flags.release: result.append("--release") + return result + +class Deploy: + """Deploy the project""" + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + + def exec(self, args: DeployArgs, flags: Optional[DeployFlags] = None) -> CliResult: + """deploy [-f --force] [--confirm] …""" + cmd_args: list[str] = ["deploy"] + if args.env is not None: cmd_args.append(str(args.env)) + if args.tags is not None: cmd_args.extend(args.tags) + flag_args = self._build_flag_args(flags) + return self._runner.run(cmd_args + flag_args) + + def _build_flag_args(self, flags: Optional[DeployFlags]) -> list[str]: + result: list[str] = [] + if flags is None: return result + if flags.verbose is not None and flags.verbose > 0: result.extend(["--verbose"] * flags.verbose) + if flags.config is not None: result.extend(["--config", str(flags.config)]) + if flags.force: result.append("--force") + if flags.confirm: result.append("--confirm") + return result diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_types.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_types.snap new file mode 100644 index 00000000..ff3170fb --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_full_feature_types.snap @@ -0,0 +1,81 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"types.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from dataclasses import dataclass +from typing import Literal, Optional, Union + +VERSION = "1.2.3" +ABOUT = "A powerful CLI tool" +AUTHOR = "Jane Doe" + +TargetChoice = Literal["debug", "release"] +EnvChoice = Literal["staging", "production"] + + +@dataclass +class GlobalFlags: + # Verbosity level. Short: -v + verbose: Optional[int] = None + # Config file path. Env: MYTOOL_CONFIG. Short: -C + config: Optional[str] = None + + +@dataclass +class MytoolArgs: + # Input file + _input: str + # Extra files + extra: list[str] + + +@dataclass +class MytoolFlags: + # Verbosity level. Short: -v + verbose: Optional[int] = None + # Config file path. Env: MYTOOL_CONFIG. Short: -C + config: Optional[str] = None + # Show what would be done + dry_run: Optional[bool] = None + + +@dataclass +class BuildArgs: + # Build target + target: TargetChoice + # Output directory + output: str + + +@dataclass +class BuildFlags: + # Verbosity level. Short: -v + verbose: Optional[int] = None + # Config file path. Env: MYTOOL_CONFIG. Short: -C + config: Optional[str] = None + # Parallel jobs. Short: -j + jobs: Optional[list[str]] = None + # Build in release mode + release: Optional[bool] = None + + +@dataclass +class DeployArgs: + # Target environment + env: EnvChoice + # Deployment tags + tags: list[str] + + +@dataclass +class DeployFlags: + # Verbosity level. Short: -v + verbose: Optional[int] = None + # Config file path. Env: MYTOOL_CONFIG. Short: -C + config: Optional[str] = None + # Force deploy. Deprecated: Use --confirm instead. Short: -f + force: Optional[bool] = None + # Confirm deployment + confirm: Optional[bool] = None diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_hyphenated_subcommands.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_hyphenated_subcommands.snap new file mode 100644 index 00000000..5246924f --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_hyphenated_subcommands.snap @@ -0,0 +1,43 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"client.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from typing import Optional +from .runtime import CliResult, CliRunner +from .types import AddRemoteArgs, RemoveRemoteArgs + +class Cli: + def __init__(self, bin_path: str = "cli") -> None: + self._runner = CliRunner(bin_path) + self.add_remote = AddRemote(self._runner) + self.remove_remote = RemoveRemote(self._runner) + + def exec(self) -> CliResult: + """""" + cmd_args: list[str] = [] + return self._runner.run(cmd_args) + +class AddRemote: + """Add a remote""" + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + + def exec(self, args: AddRemoteArgs) -> CliResult: + """add-remote """ + cmd_args: list[str] = ["add-remote"] + if args.name is not None: cmd_args.append(str(args.name)) + if args.url is not None: cmd_args.append(str(args.url)) + return self._runner.run(cmd_args) + +class RemoveRemote: + """Remove a remote""" + def __init__(self, runner: CliRunner) -> None: + self._runner = runner + + def exec(self, args: RemoveRemoteArgs) -> CliResult: + """remove-remote """ + cmd_args: list[str] = ["remove-remote"] + if args.name is not None: cmd_args.append(str(args.name)) + return self._runner.run(cmd_args) diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_init.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_init.snap new file mode 100644 index 00000000..df49b202 --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_init.snap @@ -0,0 +1,6 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"__init__.py\")" +--- +from .client import Mycli +from .types import * diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_minimal.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_minimal.snap new file mode 100644 index 00000000..8a187cfb --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_minimal.snap @@ -0,0 +1,16 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"client.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from typing import Optional +from .runtime import CliResult, CliRunner + +class Hello: + def __init__(self, bin_path: str = "hello") -> None: + self._runner = CliRunner(bin_path) + + def exec(self) -> CliResult: + cmd_args: list[str] = [] + return self._runner.run(cmd_args) diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_runtime.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_runtime.snap new file mode 100644 index 00000000..51deb0ba --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_runtime.snap @@ -0,0 +1,39 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"runtime.py\")" +--- +# Runtime module for usage-generated SDK clients. Do not edit manually. +from __future__ import annotations + +import subprocess + + +class CliResult: + """Result of a CLI invocation.""" + + def __init__(self, stdout: str, stderr: str, exit_code: int) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + @property + def ok(self) -> bool: + return self.exit_code == 0 + + +class CliRunner: + """Runs a CLI binary via subprocess.""" + + def __init__(self, bin_path: str) -> None: + self.bin_path = bin_path + + def run(self, args: list[str]) -> CliResult: + try: + result = subprocess.run( + [self.bin_path, *args], + capture_output=True, + text=True, + ) + return CliResult(result.stdout, result.stderr, result.returncode) + except FileNotFoundError: + raise RuntimeError(f"CLI binary not found: {self.bin_path}") diff --git a/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_types.snap b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_types.snap new file mode 100644 index 00000000..9a881546 --- /dev/null +++ b/lib/src/sdk/python/snapshots/usage__sdk__python__tests__python_types.snap @@ -0,0 +1,52 @@ +--- +source: lib/src/sdk/python/mod.rs +expression: "get_file(&output, \"types.py\")" +--- +# @generated by usage-cli from test.usage.kdl. Do not edit manually. +from __future__ import annotations +from dataclasses import dataclass +from typing import Literal, Optional, Union + + +Arg2Choice = Literal["choice1", "choice2", "choice3"] +ShellChoice = Literal["bash", "zsh", "fish"] + + +@dataclass +class MycliArgs: + # arg1 description + arg1: str + # arg2 description + arg2: Optional[Arg2Choice] = None + # arg3 description + arg3: str + argrest: list[str] + with_default: Optional[str] = None + + +@dataclass +class MycliFlags: + # flag1 description + flag1: Optional[bool] = None + # flag2 description + flag2: Optional[bool] = None + # flag3 description + flag3: Optional[bool] = None + with_default: bool = default value + shell: Optional[ShellChoice] = None + + +@dataclass +class InstallArgs: + plugin: str + version: str + + +@dataclass +class InstallFlags: + # Short: -g + _global: Optional[bool] = None + # Short: -d + _dir: Optional[str] = None + # Short: -f + force: Optional[bool] = None diff --git a/lib/src/sdk/rust/client.rs b/lib/src/sdk/rust/client.rs new file mode 100644 index 00000000..9cf49ef3 --- /dev/null +++ b/lib/src/sdk/rust/client.rs @@ -0,0 +1,309 @@ +use heck::AsPascalCase; + +use crate::spec::arg::SpecDoubleDashChoices; +use crate::spec::cmd::SpecCommand; +use crate::sdk::{generated_header, CodeWriter}; +use crate::{Spec, SpecFlag}; + +use super::types::{flag_property_name_rs, sanitize_rs_ident}; + +pub fn render(spec: &Spec, package_name: &str, source_file: &Option) -> String { + let mut w = CodeWriter::with_indent(" "); + + w.line(&generated_header("//!", source_file)); + w.line(""); + w.line("use crate::runtime::{CliRunner, CliResult, CliError};"); + w.line("use crate::types::*;"); + w.line(""); + + let global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + + let class_name = AsPascalCase(package_name).to_string(); + render_class(&spec.cmd, &class_name, true, &global_flags, &spec.bin, &mut w); + + w.to_string() +} + +fn render_class( + cmd: &SpecCommand, + class_name: &str, + is_root: bool, + global_flags: &[&SpecFlag], + bin_name: &str, + w: &mut CodeWriter, +) { + let visible_subcmds: Vec<_> = cmd + .subcommands + .iter() + .filter(|(_, c)| !c.hide) + .collect(); + + let visible_args: Vec<&crate::SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_args = !visible_args.is_empty(); + let has_flags = !visible_flags.is_empty() || !global_flags.is_empty(); + + // struct doc comment + if let Some(help) = &cmd.help { + w.line(&format!("/// {help}")); + } + if let Some(deprecated) = &cmd.deprecated { + w.line(&format!("/// DEPRECATED: {deprecated}")); + } + if !cmd.aliases.is_empty() { + w.line(&format!("/// Aliases: {}", cmd.aliases.join(", "))); + } + + w.line(&format!("pub struct {class_name} {{")); + w.indent(); + w.line("runner: CliRunner,"); + for (name, _) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + w.line(&format!("pub {}: {sub_class},", sanitize_rs_ident(name))); + } + w.dedent(); + w.line("}"); + + // impl block + w.line(""); + w.line(&format!("impl {class_name} {{")); + + w.indent(); + + // constructor + if is_root { + w.line(&format!("pub fn new(bin_path: &str) -> Self {{")); + w.indent(); + w.line("Self::with_runner(CliRunner::new(bin_path))"); + w.dedent(); + w.line("}"); + w.line(""); + w.line("pub fn with_runner(runner: CliRunner) -> Self {"); + w.indent(); + w.line("Self {"); + w.indent(); + w.line("runner: runner.clone(),"); + for (name, _) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + let prop = sanitize_rs_ident(name); + w.line(&format!("{prop}: {sub_class}::new(runner.clone()),")); + } + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + } else { + w.line("pub(crate) fn new(runner: CliRunner) -> Self {"); + w.indent(); + w.line("Self {"); + w.indent(); + w.line("runner: runner.clone(),"); + for (name, _) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + let prop = sanitize_rs_ident(name); + w.line(&format!("{prop}: {sub_class}::new(runner.clone()),")); + } + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + } + + // exec method + w.line(""); + + let args_type = if has_args { + format!("{class_name}Args") + } else { + String::new() + }; + let flags_type = if !global_flags.is_empty() && !visible_flags.is_empty() { + format!("{class_name}Flags") + } else if !global_flags.is_empty() && visible_flags.is_empty() { + "GlobalFlags".to_string() + } else if !visible_flags.is_empty() { + format!("{class_name}Flags") + } else { + String::new() + }; + + // doc comments on exec + if !cmd.usage.is_empty() { + w.line(&format!("/// {}", cmd.usage)); + } + for example in &cmd.examples { + let label = example.header.as_deref().unwrap_or("Example"); + w.line(&format!("/// {label}: `{}`", example.code)); + } + + let sig = if has_args && !flags_type.is_empty() { + format!("pub fn exec(&self, args: {args_type}, flags: Option<&{flags_type}>) -> Result {{") + } else if has_args { + format!("pub fn exec(&self, args: {args_type}) -> Result {{") + } else if !flags_type.is_empty() { + format!("pub fn exec(&self, flags: Option<&{flags_type}>) -> Result {{") + } else { + "pub fn exec(&self) -> Result {".to_string() + }; + w.line(&sig); + w.indent(); + + // build cmd_args + let path: String = cmd + .full_cmd + .iter() + .map(|s| format!("\"{s}\".to_string()")) + .collect::>() + .join(", "); + let mut_decl = if has_args || has_flags { "let mut cmd_args" } else { "let cmd_args" }; + w.line(&format!("{mut_decl}: Vec = vec![{path}];")); + + // push args + if has_args { + let has_required_double_dash = visible_args + .iter() + .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Required)); + let has_automatic_double_dash = visible_args + .iter() + .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Automatic)); + + for arg in &visible_args { + let ident = sanitize_rs_ident(&heck::AsSnakeCase(&arg.name).to_string()); + if arg.var { + w.line(&format!("cmd_args.extend(args.{ident}.iter().map(|v| v.to_string()));")); + } else { + let optional = !(arg.required && arg.default.is_none()); + if optional { + w.line(&format!("if let Some(v) = &args.{ident} {{ cmd_args.push(v.to_string()); }}")); + } else { + w.line(&format!("cmd_args.push(args.{ident}.to_string());")); + } + } + } + + if has_required_double_dash { + w.line("cmd_args.push(\"--\".to_string());"); + } else if has_automatic_double_dash { + w.line("// double_dash=automatic: \"--\" is implied after the first positional arg"); + } + } + + // push flags + if has_flags { + w.line(&format!( + "cmd_args.extend(Self::build_flag_args(flags));" + )); + w.line("self.runner.run(cmd_args)"); + } else { + w.line("self.runner.run(cmd_args)"); + } + + w.dedent(); + w.line("}"); + + // build_flag_args + if has_flags { + w.line(""); + w.line(&format!( + "fn build_flag_args(flags: Option<&{flags_type}>) -> Vec {{" + )); + w.indent(); + w.line("let mut result = Vec::new();"); + w.line("let Some(flags) = flags else { return result };"); + + for flag in global_flags { + render_flag_build_rs(flag, w); + } + for flag in &visible_flags { + if !global_flags.iter().any(|gf| gf.name == flag.name) { + render_flag_build_rs(flag, w); + } + } + + w.line("result"); + w.dedent(); + w.line("}"); + } + + w.dedent(); // end impl block + w.line("}"); + + // render subcommand structs + for (name, subcmd) in &visible_subcmds { + w.line(""); + let sub_class = AsPascalCase(name).to_string(); + render_class(subcmd, &sub_class, false, global_flags, bin_name, w); + } +} + +fn render_flag_build_rs(flag: &SpecFlag, w: &mut CodeWriter) { + let prop_name = flag_property_name_rs(flag); + let flag_arg_name = if let Some(long) = flag.long.first() { + format!("--{long}") + } else if let Some(short) = flag.short.first() { + format!("-{short}") + } else { + format!("--{}", flag.name) + }; + + if flag.arg.is_some() { + if flag.var { + // repeatable value flag + w.line(&format!("if let Some(v) = &flags.{prop_name} {{")); + w.indent(); + w.line(&format!("for item in v {{")); + w.indent(); + w.line(&format!("result.push(\"{flag_arg_name}\".to_string());")); + w.line("result.push(item.to_string());"); + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + } else { + // single value flag + w.line(&format!("if let Some(v) = &flags.{prop_name} {{")); + w.indent(); + w.line(&format!("result.push(\"{flag_arg_name}\".to_string());")); + w.line("result.push(v.to_string());"); + w.dedent(); + w.line("}"); + } + } else if flag.count { + // count flag + w.line(&format!("if let Some(count) = flags.{prop_name} {{")); + w.indent(); + w.line(&format!("for _ in 0..count {{ result.push(\"{flag_arg_name}\".to_string()); }}")); + w.dedent(); + w.line("}"); + } else if flag.var { + // repeatable boolean flag + w.line(&format!("if let Some(v) = &flags.{prop_name} {{")); + w.indent(); + w.line(&format!("for item in v {{")); + w.indent(); + w.line(&format!("if *item {{ result.push(\"{flag_arg_name}\".to_string()); }}")); + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + } else { + // boolean flag + w.line(&format!("if flags.{prop_name} == Some(true) {{")); + w.indent(); + w.line(&format!("result.push(\"{flag_arg_name}\".to_string());")); + w.dedent(); + w.line("}"); + if let Some(negate) = &flag.negate { + w.line(&format!("else if flags.{prop_name} == Some(false) {{")); + w.indent(); + w.line(&format!("result.push(\"{negate}\".to_string());")); + w.dedent(); + w.line("}"); + } + } +} diff --git a/lib/src/sdk/rust/mod.rs b/lib/src/sdk/rust/mod.rs new file mode 100644 index 00000000..3001fcf6 --- /dev/null +++ b/lib/src/sdk/rust/mod.rs @@ -0,0 +1,204 @@ +use std::path::PathBuf; + +use heck::AsPascalCase; +use heck::AsSnakeCase; + +use crate::sdk::{SdkFile, SdkOptions, SdkOutput}; +use crate::Spec; + +mod client; +mod runtime; +mod types; + +pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput { + let package_name = opts + .package_name + .clone() + .unwrap_or_else(|| spec.bin.clone()); + let crate_name = AsSnakeCase(&package_name).to_string(); + + SdkOutput { + files: vec![ + SdkFile { + path: PathBuf::from("src/types.rs"), + content: types::render(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("src/client.rs"), + content: client::render(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("src/runtime.rs"), + content: runtime::RUNTIME_RS.to_string(), + }, + SdkFile { + path: PathBuf::from("src/lib.rs"), + content: render_lib_rs(&package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("Cargo.toml"), + content: render_cargo_toml(&crate_name), + }, + ], + } +} + +fn render_lib_rs(package_name: &str, source_file: &Option) -> String { + let mut w = crate::sdk::CodeWriter::with_indent(" "); + let header = crate::sdk::generated_header("//!", source_file); + w.line(&header); + w.line(""); + w.line("pub mod runtime;"); + w.line("pub mod types;"); + w.line("pub mod client;"); + w.line(""); + let class_name = AsPascalCase(package_name).to_string(); + w.line(&format!("pub use client::{class_name};")); + w.line("pub use types::*;"); + w.line("pub use runtime::{CliResult, CliError, CliRunner};"); + w.to_string() +} + +fn render_cargo_toml(crate_name: &str) -> String { + format!( + r#"[package] +name = "{crate_name}-sdk" +version = "0.1.0" +edition = "2021" +"# + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use crate::sdk::{SdkLanguage, SdkOptions}; + use crate::test::SPEC_KITCHEN_SINK; + use crate::Spec; + + fn make_opts() -> SdkOptions { + SdkOptions { + language: SdkLanguage::Rust, + package_name: None, + source_file: Some("test.usage.kdl".to_string()), + } + } + + fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str { + output + .files + .iter() + .find(|f| f.path.to_str() == Some(name)) + .unwrap_or_else(|| panic!("{name} should exist")) + .content + .as_str() + } + + #[test] + fn test_rust_types() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/types.rs")); + } + + #[test] + fn test_rust_client() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/client.rs")); + } + + #[test] + fn test_rust_runtime() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/runtime.rs")); + } + + #[test] + fn test_rust_lib() { + let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/lib.rs")); + } + + fn full_feature_spec() -> Spec { + r##" + bin "mytool" + name "mytool" + version "1.2.3" + about "A powerful CLI tool" + author "Jane Doe" + + flag "-v --verbose" help="Verbosity level" count=#true global=#true + flag "-C --config " help="Config file path" global=#true env="MYTOOL_CONFIG" + flag "--dry-run" help="Show what would be done" negate="--no-dry-run" + + arg "input" help="Input file" required=#true + arg "extra" var=#true help="Extra files" + + cmd "build" help="Build the project" deprecated="Use compile instead" { + alias "b" + arg "target" help="Build target" { + choices "debug" "release" + } + arg "output" help="Output directory" double_dash="required" + flag "-j --jobs " help="Parallel jobs" var=#true + flag "--release" help="Build in release mode" + } + + cmd "deploy" help="Deploy the project" { + arg "env" help="Target environment" { + choices "staging" "production" + } + arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5 + flag "-f --force" help="Force deploy" deprecated="Use --confirm instead" + flag "--confirm" help="Confirm deployment" + } + "## + .parse() + .unwrap() + } + + #[test] + fn test_rust_full_feature_types() { + let spec = full_feature_spec(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/types.rs")); + } + + #[test] + fn test_rust_full_feature_client() { + let spec = full_feature_spec(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/client.rs")); + } + + #[test] + fn test_rust_hyphenated_subcommands() { + let spec: Spec = r##" + bin "cli" + cmd "add-remote" help="Add a remote" { + arg "name" + arg "url" + } + cmd "remove-remote" help="Remove a remote" { + arg "name" + } + "## + .parse() + .unwrap(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/client.rs")); + } + + #[test] + fn test_rust_minimal() { + let spec: Spec = r##" + bin "hello" + "## + .parse() + .unwrap(); + let output = crate::sdk::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "src/client.rs")); + } +} diff --git a/lib/src/sdk/rust/runtime.rs b/lib/src/sdk/rust/runtime.rs new file mode 100644 index 00000000..6c48f087 --- /dev/null +++ b/lib/src/sdk/rust/runtime.rs @@ -0,0 +1,72 @@ +pub const RUNTIME_RS: &str = r#"//! Runtime module for usage-generated SDK clients. Do not edit manually. +use std::process::Command; + +/// Result of a CLI invocation. +#[derive(Debug, Clone)] +pub struct CliResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl CliResult { + pub fn ok(&self) -> bool { + self.exit_code == 0 + } +} + +/// Errors that can occur during CLI invocation. +#[derive(Debug)] +pub enum CliError { + Io(std::io::Error), + NotFound(String), +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliError::Io(e) => write!(f, "IO error: {e}"), + CliError::NotFound(bin) => write!(f, "CLI binary not found: {bin}"), + } + } +} + +impl std::error::Error for CliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CliError::Io(e) => Some(e), + CliError::NotFound(_) => None, + } + } +} + +/// Runs a CLI binary via std::process::Command. +#[derive(Debug, Clone)] +pub struct CliRunner { + bin_path: String, +} + +impl CliRunner { + pub fn new(bin_path: &str) -> Self { + Self { bin_path: bin_path.to_string() } + } + + pub fn run(&self, args: Vec) -> Result { + let output = Command::new(&self.bin_path) + .args(&args) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CliError::NotFound(self.bin_path.clone()) + } else { + CliError::Io(e) + } + })?; + Ok(CliResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(1), + }) + } +} +"#; diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_client.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_client.snap new file mode 100644 index 00000000..d929260f --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_client.snap @@ -0,0 +1,123 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/client.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use crate::runtime::{CliRunner, CliResult, CliError}; +use crate::types::*; + +pub struct Mycli { + runner: CliRunner, + pub plugin: Plugin, +} + +impl Mycli { + pub fn new(bin_path: &str) -> Self { + Self::with_runner(CliRunner::new(bin_path)) + } + + pub fn with_runner(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + plugin: Plugin::new(runner.clone()), + } + } + + /// [FLAGS] + pub fn exec(&self, args: MycliArgs, flags: Option<&MycliFlags>) -> Result { + let mut cmd_args: Vec = vec![]; + cmd_args.push(args.arg1.to_string()); + if let Some(v) = &args.arg2 { cmd_args.push(v.to_string()); } + cmd_args.push(args.arg3.to_string()); + cmd_args.extend(args.argrest.iter().map(|v| v.to_string())); + if let Some(v) = &args.with_default { cmd_args.push(v.to_string()); } + cmd_args.extend(Self::build_flag_args(flags)); + self.runner.run(cmd_args) + } + + fn build_flag_args(flags: Option<&MycliFlags>) -> Vec { + let mut result = Vec::new(); + let Some(flags) = flags else { return result }; + if flags.flag1 == Some(true) { + result.push("--flag1".to_string()); + } + if flags.flag2 == Some(true) { + result.push("--flag2".to_string()); + } + if flags.flag3 == Some(true) { + result.push("--flag3".to_string()); + } + else if flags.flag3 == Some(false) { + result.push("--no-flag3".to_string()); + } + if flags.with_default == Some(true) { + result.push("--with-default".to_string()); + } + if let Some(v) = &flags.shell { + result.push("--shell".to_string()); + result.push(v.to_string()); + } + result + } +} + +pub struct Plugin { + runner: CliRunner, + pub install: Install, +} + +impl Plugin { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + install: Install::new(runner.clone()), + } + } + + /// plugin + pub fn exec(&self) -> Result { + let cmd_args: Vec = vec!["plugin".to_string()]; + self.runner.run(cmd_args) + } +} + +pub struct Install { + runner: CliRunner, +} + +impl Install { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + /// plugin install [FLAGS] + pub fn exec(&self, args: InstallArgs, flags: Option<&InstallFlags>) -> Result { + let mut cmd_args: Vec = vec!["plugin".to_string(), "install".to_string()]; + cmd_args.push(args.plugin.to_string()); + cmd_args.push(args.version.to_string()); + cmd_args.extend(Self::build_flag_args(flags)); + self.runner.run(cmd_args) + } + + fn build_flag_args(flags: Option<&InstallFlags>) -> Vec { + let mut result = Vec::new(); + let Some(flags) = flags else { return result }; + if flags.global == Some(true) { + result.push("--global".to_string()); + } + if let Some(v) = &flags.dir { + result.push("--dir".to_string()); + result.push(v.to_string()); + } + if flags.force == Some(true) { + result.push("--force".to_string()); + } + else if flags.force == Some(false) { + result.push("--no-force".to_string()); + } + result + } +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_client.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_client.snap new file mode 100644 index 00000000..f549da49 --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_client.snap @@ -0,0 +1,144 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/client.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use crate::runtime::{CliRunner, CliResult, CliError}; +use crate::types::*; + +pub struct Mytool { + runner: CliRunner, + pub build: Build, + pub deploy: Deploy, +} + +impl Mytool { + pub fn new(bin_path: &str) -> Self { + Self::with_runner(CliRunner::new(bin_path)) + } + + pub fn with_runner(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + build: Build::new(runner.clone()), + deploy: Deploy::new(runner.clone()), + } + } + + /// [FLAGS] + pub fn exec(&self, args: MytoolArgs, flags: Option<&MytoolFlags>) -> Result { + let mut cmd_args: Vec = vec![]; + cmd_args.push(args.input.to_string()); + cmd_args.extend(args.extra.iter().map(|v| v.to_string())); + cmd_args.extend(Self::build_flag_args(flags)); + self.runner.run(cmd_args) + } + + fn build_flag_args(flags: Option<&MytoolFlags>) -> Vec { + let mut result = Vec::new(); + let Some(flags) = flags else { return result }; + if let Some(count) = flags.verbose { + for _ in 0..count { result.push("--verbose".to_string()); } + } + if let Some(v) = &flags.config { + result.push("--config".to_string()); + result.push(v.to_string()); + } + if flags.dry_run == Some(true) { + result.push("--dry-run".to_string()); + } + else if flags.dry_run == Some(false) { + result.push("--no-dry-run".to_string()); + } + result + } +} + +/// Build the project +/// DEPRECATED: Use compile instead +/// Aliases: b +pub struct Build { + runner: CliRunner, +} + +impl Build { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + /// build [-j --jobs… ] [--release] <-- output> + pub fn exec(&self, args: BuildArgs, flags: Option<&BuildFlags>) -> Result { + let mut cmd_args: Vec = vec!["build".to_string()]; + cmd_args.push(args.target.to_string()); + cmd_args.push(args.output.to_string()); + cmd_args.push("--".to_string()); + cmd_args.extend(Self::build_flag_args(flags)); + self.runner.run(cmd_args) + } + + fn build_flag_args(flags: Option<&BuildFlags>) -> Vec { + let mut result = Vec::new(); + let Some(flags) = flags else { return result }; + if let Some(count) = flags.verbose { + for _ in 0..count { result.push("--verbose".to_string()); } + } + if let Some(v) = &flags.config { + result.push("--config".to_string()); + result.push(v.to_string()); + } + if let Some(v) = &flags.jobs { + for item in v { + result.push("--jobs".to_string()); + result.push(item.to_string()); + } + } + if flags.release == Some(true) { + result.push("--release".to_string()); + } + result + } +} + +/// Deploy the project +pub struct Deploy { + runner: CliRunner, +} + +impl Deploy { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + /// deploy [-f --force] [--confirm] … + pub fn exec(&self, args: DeployArgs, flags: Option<&DeployFlags>) -> Result { + let mut cmd_args: Vec = vec!["deploy".to_string()]; + cmd_args.push(args.env.to_string()); + cmd_args.extend(args.tags.iter().map(|v| v.to_string())); + cmd_args.extend(Self::build_flag_args(flags)); + self.runner.run(cmd_args) + } + + fn build_flag_args(flags: Option<&DeployFlags>) -> Vec { + let mut result = Vec::new(); + let Some(flags) = flags else { return result }; + if let Some(count) = flags.verbose { + for _ in 0..count { result.push("--verbose".to_string()); } + } + if let Some(v) = &flags.config { + result.push("--config".to_string()); + result.push(v.to_string()); + } + if flags.force == Some(true) { + result.push("--force".to_string()); + } + if flags.confirm == Some(true) { + result.push("--confirm".to_string()); + } + result + } +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_types.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_types.snap new file mode 100644 index 00000000..0e4516d8 --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_full_feature_types.snap @@ -0,0 +1,126 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/types.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use std::fmt; + +pub const VERSION: &str = "1.2.3"; +pub const ABOUT: &str = "A powerful CLI tool"; +pub const AUTHOR: &str = "Jane Doe"; + +#[derive(Debug, Clone, PartialEq)] +pub enum TargetChoice { + Debug, + Release, +} + +impl fmt::Display for TargetChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Debug => write!(f, "debug"), + Self::Release => write!(f, "release"), + } + } +} + +impl TargetChoice { + pub fn as_str(&self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EnvChoice { + Staging, + Production, +} + +impl fmt::Display for EnvChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Staging => write!(f, "staging"), + Self::Production => write!(f, "production"), + } + } +} + +impl EnvChoice { + pub fn as_str(&self) -> &'static str { + match self { + Self::Staging => "staging", + Self::Production => "production", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct GlobalFlags { + /// Verbosity level + pub verbose: Option, + /// Config file path. Env: MYTOOL_CONFIG + pub config: Option, +} + +#[derive(Debug, Clone)] +pub struct MytoolArgs { + /// Input file + pub input: String, + /// Extra files + pub extra: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MytoolFlags { + /// Verbosity level + pub verbose: Option, + /// Config file path. Env: MYTOOL_CONFIG + pub config: Option, + /// Show what would be done + pub dry_run: Option, +} + +#[derive(Debug, Clone)] +pub struct BuildArgs { + /// Build target + pub target: TargetChoice, + /// Output directory + pub output: String, +} + +#[derive(Debug, Clone, Default)] +pub struct BuildFlags { + /// Verbosity level + pub verbose: Option, + /// Config file path. Env: MYTOOL_CONFIG + pub config: Option, + /// Parallel jobs + pub jobs: Option>, + /// Build in release mode + pub release: Option, +} + +#[derive(Debug, Clone)] +pub struct DeployArgs { + /// Target environment + pub env: EnvChoice, + /// Deployment tags + pub tags: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct DeployFlags { + /// Verbosity level + pub verbose: Option, + /// Config file path. Env: MYTOOL_CONFIG + pub config: Option, + /// Force deploy + #[deprecated = "Use --confirm instead"] + pub force: Option, + /// Confirm deployment + pub confirm: Option, +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_hyphenated_subcommands.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_hyphenated_subcommands.snap new file mode 100644 index 00000000..f2e9a60c --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_hyphenated_subcommands.snap @@ -0,0 +1,75 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/client.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use crate::runtime::{CliRunner, CliResult, CliError}; +use crate::types::*; + +pub struct Cli { + runner: CliRunner, + pub add_remote: AddRemote, + pub remove_remote: RemoveRemote, +} + +impl Cli { + pub fn new(bin_path: &str) -> Self { + Self::with_runner(CliRunner::new(bin_path)) + } + + pub fn with_runner(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + add_remote: AddRemote::new(runner.clone()), + remove_remote: RemoveRemote::new(runner.clone()), + } + } + + /// + pub fn exec(&self) -> Result { + let cmd_args: Vec = vec![]; + self.runner.run(cmd_args) + } +} + +/// Add a remote +pub struct AddRemote { + runner: CliRunner, +} + +impl AddRemote { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + /// add-remote + pub fn exec(&self, args: AddRemoteArgs) -> Result { + let mut cmd_args: Vec = vec!["add-remote".to_string()]; + cmd_args.push(args.name.to_string()); + cmd_args.push(args.url.to_string()); + self.runner.run(cmd_args) + } +} + +/// Remove a remote +pub struct RemoveRemote { + runner: CliRunner, +} + +impl RemoveRemote { + pub(crate) fn new(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + /// remove-remote + pub fn exec(&self, args: RemoveRemoteArgs) -> Result { + let mut cmd_args: Vec = vec!["remove-remote".to_string()]; + cmd_args.push(args.name.to_string()); + self.runner.run(cmd_args) + } +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_lib.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_lib.snap new file mode 100644 index 00000000..1db2c13e --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_lib.snap @@ -0,0 +1,13 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/lib.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +pub mod runtime; +pub mod types; +pub mod client; + +pub use client::Mycli; +pub use types::*; +pub use runtime::{CliResult, CliError, CliRunner}; diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_minimal.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_minimal.snap new file mode 100644 index 00000000..d6f19361 --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_minimal.snap @@ -0,0 +1,29 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/client.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use crate::runtime::{CliRunner, CliResult, CliError}; +use crate::types::*; + +pub struct Hello { + runner: CliRunner, +} + +impl Hello { + pub fn new(bin_path: &str) -> Self { + Self::with_runner(CliRunner::new(bin_path)) + } + + pub fn with_runner(runner: CliRunner) -> Self { + Self { + runner: runner.clone(), + } + } + + pub fn exec(&self) -> Result { + let cmd_args: Vec = vec![]; + self.runner.run(cmd_args) + } +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_runtime.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_runtime.snap new file mode 100644 index 00000000..90e2c86c --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_runtime.snap @@ -0,0 +1,75 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/runtime.rs\")" +--- +//! Runtime module for usage-generated SDK clients. Do not edit manually. +use std::process::Command; + +/// Result of a CLI invocation. +#[derive(Debug, Clone)] +pub struct CliResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl CliResult { + pub fn ok(&self) -> bool { + self.exit_code == 0 + } +} + +/// Errors that can occur during CLI invocation. +#[derive(Debug)] +pub enum CliError { + Io(std::io::Error), + NotFound(String), +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliError::Io(e) => write!(f, "IO error: {e}"), + CliError::NotFound(bin) => write!(f, "CLI binary not found: {bin}"), + } + } +} + +impl std::error::Error for CliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CliError::Io(e) => Some(e), + CliError::NotFound(_) => None, + } + } +} + +/// Runs a CLI binary via std::process::Command. +#[derive(Debug, Clone)] +pub struct CliRunner { + bin_path: String, +} + +impl CliRunner { + pub fn new(bin_path: &str) -> Self { + Self { bin_path: bin_path.to_string() } + } + + pub fn run(&self, args: Vec) -> Result { + let output = Command::new(&self.bin_path) + .args(&args) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CliError::NotFound(self.bin_path.clone()) + } else { + CliError::Io(e) + } + })?; + Ok(CliResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(1), + }) + } +} diff --git a/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_types.snap b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_types.snap new file mode 100644 index 00000000..c68d3a9c --- /dev/null +++ b/lib/src/sdk/rust/snapshots/usage__sdk__rust__tests__rust_types.snap @@ -0,0 +1,100 @@ +--- +source: lib/src/sdk/rust/mod.rs +expression: "get_file(&output, \"src/types.rs\")" +--- +//! @generated by usage-cli from test.usage.kdl. Do not edit manually. + +use std::fmt; + + +#[derive(Debug, Clone, PartialEq)] +pub enum Arg2Choice { + Choice1, + Choice2, + Choice3, +} + +impl fmt::Display for Arg2Choice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Choice1 => write!(f, "choice1"), + Self::Choice2 => write!(f, "choice2"), + Self::Choice3 => write!(f, "choice3"), + } + } +} + +impl Arg2Choice { + pub fn as_str(&self) -> &'static str { + match self { + Self::Choice1 => "choice1", + Self::Choice2 => "choice2", + Self::Choice3 => "choice3", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ShellChoice { + Bash, + Zsh, + Fish, +} + +impl fmt::Display for ShellChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bash => write!(f, "bash"), + Self::Zsh => write!(f, "zsh"), + Self::Fish => write!(f, "fish"), + } + } +} + +impl ShellChoice { + pub fn as_str(&self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Zsh => "zsh", + Self::Fish => "fish", + } + } +} + +#[derive(Debug, Clone)] +pub struct MycliArgs { + /// arg1 description + pub arg1: String, + /// arg2 description + pub arg2: Option = Some("default value".to_string()), + /// arg3 description + pub arg3: String, + pub argrest: Vec, + pub with_default: Option = Some("default value".to_string()), +} + +#[derive(Debug, Clone, Default)] +pub struct MycliFlags { + /// flag1 description + pub flag1: Option, + /// flag2 description + pub flag2: Option, + /// flag3 description + pub flag3: Option, + /// Default: default value + pub with_default: Option, + pub shell: Option, +} + +#[derive(Debug, Clone)] +pub struct InstallArgs { + pub plugin: String, + pub version: String, +} + +#[derive(Debug, Clone, Default)] +pub struct InstallFlags { + pub global: Option, + pub dir: Option, + pub force: Option, +} diff --git a/lib/src/sdk/rust/types.rs b/lib/src/sdk/rust/types.rs new file mode 100644 index 00000000..d966bdb1 --- /dev/null +++ b/lib/src/sdk/rust/types.rs @@ -0,0 +1,372 @@ +use heck::AsPascalCase; +use indexmap::IndexMap; + +use crate::sdk::{collect_choice_types, command_type_name, generated_header, CodeWriter}; +use crate::spec::cmd::SpecCommand; +use crate::spec::config::SpecConfigProp; +use crate::spec::data_types::SpecDataTypes; +use crate::{SpecArg, SpecFlag}; + +pub fn render( + spec: &crate::Spec, + package_name: &str, + source_file: &Option, +) -> String { + let mut w = CodeWriter::with_indent(" "); + + w.line(&generated_header("//!", source_file)); + + let choice_types = collect_choice_types(&spec.cmd); + if !choice_types.is_empty() { + w.line(""); + w.line("use std::fmt;"); + } + w.line(""); + + let root_global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + let has_global_flags = !root_global_flags.is_empty(); + + // spec metadata + if let Some(version) = &spec.version { + w.line(&format!("pub const VERSION: &str = \"{version}\";")); + } + if let Some(about) = &spec.about { + w.line(&format!("pub const ABOUT: &str = \"{about}\";")); + } + if let Some(author) = &spec.author { + w.line(&format!("pub const AUTHOR: &str = \"{author}\";")); + } + + // choice enums + if !choice_types.is_empty() { + w.line(""); + for (i, (name, choices)) in choice_types.iter().enumerate() { + if i > 0 { + w.line(""); + } + render_choice_enum(name, choices, &mut w); + } + } + + // GlobalFlags + if has_global_flags { + w.line(""); + render_flags_struct("GlobalFlags", &root_global_flags, &choice_types, &mut w); + } + + // command types + render_command_types(&spec.cmd, package_name, &choice_types, has_global_flags, &root_global_flags, &mut w); + + // Config + if !spec.config.props.is_empty() { + w.line(""); + let config_name = format!("{}Config", AsPascalCase(package_name)); + render_config_struct(&config_name, &spec.config.props, &mut w); + } + + w.to_string() +} + +// --------------------------------------------------------------------------- +// Choice enums +// --------------------------------------------------------------------------- + +fn render_choice_enum(name: &str, choices: &[String], w: &mut CodeWriter) { + w.line("#[derive(Debug, Clone, PartialEq)]"); + w.line(&format!("pub enum {name} {{")); + w.indent(); + for choice in choices { + let variant = AsPascalCase(choice).to_string(); + w.line(&format!("{variant},")); + } + w.dedent(); + w.line("}"); + + // Display impl + w.line(""); + w.line(&format!("impl fmt::Display for {name} {{")); + w.indent(); + w.line("fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {"); + w.indent(); + w.line(&format!("match self {{")); + w.indent(); + for choice in choices { + let variant = AsPascalCase(choice).to_string(); + w.line(&format!("Self::{variant} => write!(f, \"{choice}\"),")); + } + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + + // as_str method + w.line(""); + w.line(&format!("impl {name} {{")); + w.indent(); + w.line("pub fn as_str(&self) -> &'static str {"); + w.indent(); + w.line("match self {"); + w.indent(); + for choice in choices { + let variant = AsPascalCase(choice).to_string(); + w.line(&format!("Self::{variant} => \"{choice}\",")); + } + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); + w.dedent(); + w.line("}"); +} + +// --------------------------------------------------------------------------- +// Command types +// --------------------------------------------------------------------------- + +fn render_command_types( + cmd: &SpecCommand, + package_name: &str, + choice_types: &IndexMap>, + has_global_flags: bool, + global_flags: &[&SpecFlag], + w: &mut CodeWriter, +) { + if cmd.hide { + return; + } + + let name = command_type_name(cmd, package_name); + let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_any_flags = !visible_flags.is_empty() || has_global_flags; + + if !visible_args.is_empty() { + w.line(""); + render_args_struct(&format!("{name}Args"), &visible_args, choice_types, w); + } + + if has_any_flags { + w.line(""); + let all_flags: Vec<&SpecFlag> = if has_global_flags { + global_flags + .iter() + .copied() + .chain(visible_flags.iter().filter(|f| !global_flags.iter().any(|gf| gf.name == f.name)).copied()) + .collect() + } else { + visible_flags + }; + render_flags_struct(&format!("{name}Flags"), &all_flags, choice_types, w); + } + + for subcmd in cmd.subcommands.values() { + render_command_types(subcmd, package_name, choice_types, has_global_flags, global_flags, w); + } +} + +fn render_args_struct( + name: &str, + args: &[&SpecArg], + choice_types: &IndexMap>, + w: &mut CodeWriter, +) { + let all_optional = args.iter().all(|a| !a.required || a.default.is_some()); + let derives = if all_optional { "#[derive(Debug, Clone, Default)]" } else { "#[derive(Debug, Clone)]" }; + w.line(derives); + w.line(&format!("pub struct {name} {{")); + w.indent(); + for arg in args { + let rs_type = arg_rs_type(arg, choice_types); + let field_name = sanitize_rs_ident(&heck::AsSnakeCase(&arg.name).to_string()); + let optional = !(arg.required && arg.default.is_none()); + + let field = if let Some(default) = &arg.default { + if optional { + format!("pub {field_name}: Option<{rs_type}> = Some(\"{default}\".to_string()),") + } else { + format!("pub {field_name}: {rs_type} = \"{default}\".to_string(),") + } + } else if optional { + format!("pub {field_name}: Option<{rs_type}> = None,") + } else { + format!("pub {field_name}: {rs_type},") + }; + + if let Some(help) = &arg.help { + w.line(&format!("/// {help}")); + } + w.line(&field); + } + w.dedent(); + w.line("}"); +} + +fn render_flags_struct( + name: &str, + flags: &[&SpecFlag], + choice_types: &IndexMap>, + w: &mut CodeWriter, +) { + w.line("#[derive(Debug, Clone, Default)]"); + w.line(&format!("pub struct {name} {{")); + w.indent(); + for flag in flags { + let rs_type = flag_rs_type(flag, choice_types); + let prop_name = flag_property_name_rs(flag); + let field = format!("pub {prop_name}: Option<{rs_type}>,"); + render_flag_docs(flag, w); + w.line(&field); + } + w.dedent(); + w.line("}"); +} + +fn render_config_struct( + name: &str, + props: &std::collections::BTreeMap, + w: &mut CodeWriter, +) { + let all_optional = props.iter().all(|(_, p)| p.default.is_some()); + let derives = if all_optional { "#[derive(Debug, Clone, Default)]" } else { "#[derive(Debug, Clone)]" }; + w.line(derives); + w.line(&format!("pub struct {name} {{")); + w.indent(); + for (field_name, prop) in props { + let rs_type = config_prop_type(prop); + let field = if prop.default.is_some() { + let d = prop.default.as_ref().unwrap(); + match prop.data_type { + SpecDataTypes::Boolean => format!("pub {field_name}: Option<{rs_type}> = Some({d}),"), + SpecDataTypes::Integer | SpecDataTypes::Float => format!("pub {field_name}: Option<{rs_type}> = Some({d}),"), + _ => format!("pub {field_name}: Option<{rs_type}> = Some(\"{d}\".to_string()),"), + } + } else { + format!("pub {field_name}: {rs_type},") + }; + if let Some(help) = &prop.help { + w.line(&format!("/// {help}")); + } + w.line(&field); + } + w.dedent(); + w.line("}"); +} + +fn render_flag_docs(flag: &SpecFlag, w: &mut CodeWriter) { + let mut doc_parts = Vec::new(); + if let Some(help) = &flag.help { + doc_parts.push(help.clone()); + } + if let Some(env) = &flag.env { + doc_parts.push(format!("Env: {env}")); + } + if let Some(default) = &flag.default { + doc_parts.push(format!("Default: {default}")); + } + // aliases in doc + if flag.long.len() > 1 { + let aliases: Vec<&str> = flag.long.iter().skip(1).map(|s| s.as_str()).collect(); + doc_parts.push(format!("Aliases: {}", aliases.join(", "))); + } + if !doc_parts.is_empty() { + w.line(&format!("/// {}", doc_parts.join(". "))); + } + if let Some(deprecated) = &flag.deprecated { + w.line(&format!("#[deprecated = \"{deprecated}\"]")); + } +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +fn arg_rs_type(arg: &SpecArg, choice_types: &IndexMap>) -> String { + let base = if let Some(_choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&arg.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + "String".to_string() + } + } else { + "String".to_string() + }; + + if arg.var { + format!("Vec<{base}>") + } else { + base + } +} + +fn flag_rs_type(flag: &SpecFlag, choice_types: &IndexMap>) -> String { + if flag.count { + return "i32".to_string(); + } + + match &flag.arg { + Some(arg) => { + let base = if let Some(_choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&flag.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + "String".to_string() + } + } else { + "String".to_string() + }; + + if flag.var { + format!("Vec<{base}>") + } else { + base + } + } + None => "bool".to_string(), + } +} + +fn config_prop_type(prop: &SpecConfigProp) -> String { + match prop.data_type { + SpecDataTypes::String => "String".to_string(), + SpecDataTypes::Integer => "i64".to_string(), + SpecDataTypes::Float => "f64".to_string(), + SpecDataTypes::Boolean => "bool".to_string(), + SpecDataTypes::Null => "String".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Naming +// --------------------------------------------------------------------------- + +pub fn flag_property_name_rs(flag: &SpecFlag) -> String { + if let Some(long) = flag.long.first() { + return sanitize_rs_ident(&heck::AsSnakeCase(long).to_string()); + } + if let Some(short) = flag.short.first() { + return short.to_string(); + } + sanitize_rs_ident(&heck::AsSnakeCase(&flag.name).to_string()) +} + +pub fn sanitize_rs_ident(name: &str) -> String { + let snake = heck::AsSnakeCase(name).to_string(); + match snake.as_str() { + "type" | "self" | "super" | "mod" | "use" | "fn" | "let" | "mut" | "pub" + | "impl" | "trait" | "struct" | "enum" | "match" | "if" | "else" | "for" + | "while" | "loop" | "return" | "break" | "continue" | "where" | "as" + | "in" | "ref" | "move" | "async" | "await" | "unsafe" | "static" + | "const" | "dyn" | "true" | "false" | "crate" | "extern" | "default" + | "macro" | "yield" | "box" | "override" | "abstract" => format!("_{snake}"), + _ => snake, + } +} diff --git a/lib/src/sdk/typescript/mod.rs b/lib/src/sdk/typescript/mod.rs new file mode 100644 index 00000000..a1a5a644 --- /dev/null +++ b/lib/src/sdk/typescript/mod.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use crate::sdk::{SdkFile, SdkOptions, SdkOutput}; +use crate::Spec; + +mod runtime; +mod types; +mod wrappers; + +pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput { + let package_name = opts + .package_name + .clone() + .unwrap_or_else(|| spec.bin.clone()); + + SdkOutput { + files: vec![ + SdkFile { + path: PathBuf::from("types.ts"), + content: types::render(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("client.ts"), + content: wrappers::render(spec, &package_name, &opts.source_file), + }, + SdkFile { + path: PathBuf::from("runtime.ts"), + content: runtime::RUNTIME_TS.to_string(), + }, + SdkFile { + path: PathBuf::from("index.ts"), + content: render_index(&package_name), + }, + ], + } +} + +fn render_index(package_name: &str) -> String { + let class_name = heck::AsPascalCase(package_name).to_string(); + format!( + "export {{ {class_name} }} from \"./client\";\nexport * from \"./types\";\n" + ) +} diff --git a/lib/src/sdk/typescript/runtime.rs b/lib/src/sdk/typescript/runtime.rs new file mode 100644 index 00000000..dded3821 --- /dev/null +++ b/lib/src/sdk/typescript/runtime.rs @@ -0,0 +1,43 @@ +pub const RUNTIME_TS: &str = r#"// Runtime module for usage-generated SDK clients. Do not edit manually. +import { execFileSync } from "node:child_process"; + +export class CliResult { + constructor( + public readonly stdout: string, + public readonly stderr: string, + public readonly exitCode: number, + ) {} + + get ok(): boolean { + return this.exitCode === 0; + } +} + +export class CliRunner { + constructor(private binPath: string) {} + + run(args: string[], flags?: Record): CliResult { + const flagArgs: string[] = []; + if (flags) { + for (const [key, value] of Object.entries(flags)) { + if (value === undefined || value === null) continue; + if (typeof value === "boolean") { + if (value) flagArgs.push(`--${key}`); + } else { + flagArgs.push(`--${key}`, String(value)); + } + } + } + + try { + const stdout = execFileSync(this.binPath, [...args, ...flagArgs], { + encoding: "utf-8", + }); + return new CliResult(stdout, "", 0); + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; status?: number }; + return new CliResult(err.stdout ?? "", err.stderr ?? "", err.status ?? 1); + } + } +} +"#; diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__deep_nesting.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__deep_nesting.snap new file mode 100644 index 00000000..6da71b48 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__deep_nesting.snap @@ -0,0 +1,102 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"client.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +import { CliRunner, CliResult } from "./runtime"; +import { CreateArgs, CreateFlags, RunFlags } from "./types"; + +export class App { + private runner: CliRunner; + /** Database operations */ + readonly db: Db; + + constructor(binPath?: string) { + this.runner = new CliRunner(binPath ?? "app"); + this.db = new Db(this.runner); + } + /** */ + exec(): CliResult { + return this.runner.run([]); + } +} + +/** Database operations */ +export class Db { + private runner: CliRunner; + /** Migration management */ + readonly migration: Migration; + + constructor(runner: CliRunner) { + this.runner = runner; + this.migration = new Migration(this.runner); + } + /** db */ + exec(): CliResult { + return this.runner.run(["db"]); + } +} + +/** Migration management */ +export class Migration { + private runner: CliRunner; + /** Create a new migration */ + readonly create: Create; + /** Run pending migrations */ + readonly run: Run; + + constructor(runner: CliRunner) { + this.runner = runner; + this.create = new Create(this.runner); + this.run = new Run(this.runner); + } + /** db migration */ + exec(): CliResult { + return this.runner.run(["db", "migration"]); + } +} + +/** Create a new migration */ +export class Create { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** db migration create [--template ] */ + exec(args: CreateArgs, flags?: CreateFlags): CliResult { + const cmdArgs: string[] = ["db", "migration", "create"]; + if (args.name !== undefined) { cmdArgs.push(String(args.name)); } + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: CreateFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.template !== undefined) { result.push("--template", String(flags.template)); } + return result; + } +} + +/** Run pending migrations */ +export class Run { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** db migration run [--step ] */ + exec(flags?: RunFlags): CliResult { + const cmdArgs: string[] = ["db", "migration", "run"]; + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: RunFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.step !== undefined) { result.push("--step", String(flags.step)); } + return result; + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_client.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_client.snap new file mode 100644 index 00000000..902c16b3 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_client.snap @@ -0,0 +1,99 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"client.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +import { CliRunner, CliResult } from "./runtime"; +import { BuildArgs, BuildFlags, DeployArgs, DeployFlags, EnvChoice, GlobalFlags, MytoolArgs, MytoolFlags, TargetChoice } from "./types"; + +export class Mytool { + private runner: CliRunner; + /** Build the project. @deprecated Use 'compile' instead */ + readonly build: Build; + /** Deploy the project */ + readonly deploy: Deploy; + /** Alias for `build` */ + get b(): Build { return this.build; } + + constructor(binPath?: string) { + this.runner = new CliRunner(binPath ?? "mytool"); + this.build = new Build(this.runner); + this.deploy = new Deploy(this.runner); + } + /** [FLAGS] */ + exec(args: MytoolArgs, flags?: MytoolFlags): CliResult { + const cmdArgs: string[] = []; + if (args.input !== undefined) { cmdArgs.push(String(args.input)); } + if (args.extra !== undefined) { cmdArgs.push(...args.extra); } + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: MytoolFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.verbose !== undefined && flags.verbose > 0) { for (let i = 0; i < flags.verbose; i++) { result.push("--verbose"); } } + if (flags.config !== undefined) { result.push("--config", String(flags.config)); } + if (flags.dryRun) { result.push("--dry-run"); } + else if (flags.dryRun === false) { result.push("--no-dry-run"); } + return result; + } +} + +/** Build the project\n@deprecated Use 'compile' instead\nAliases: b */ +export class Build { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** build [-j --jobs… ] [--release] <-- output>\n@example Build in release mode +```bash +mytool build --release target +``` */ + exec(args: BuildArgs, flags?: BuildFlags): CliResult { + const cmdArgs: string[] = ["build"]; + if (args.target !== undefined) { cmdArgs.push(String(args.target)); } + if (args.output !== undefined) { cmdArgs.push(String(args.output)); } + cmdArgs.push("--"); + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: BuildFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.verbose !== undefined && flags.verbose > 0) { for (let i = 0; i < flags.verbose; i++) { result.push("--verbose"); } } + if (flags.config !== undefined) { result.push("--config", String(flags.config)); } + if (flags.jobs !== undefined) { for (const v of flags.jobs) { result.push("--jobs", String(v)); } } + if (flags.release) { result.push("--release"); } + return result; + } +} + +/** Deploy the project */ +export class Deploy { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** deploy [-f --force] [--confirm] … */ + exec(args: DeployArgs, flags?: DeployFlags): CliResult { + const cmdArgs: string[] = ["deploy"]; + if (args.env !== undefined) { cmdArgs.push(String(args.env)); } + if (args.tags !== undefined) { cmdArgs.push(...args.tags); } + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: DeployFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.verbose !== undefined && flags.verbose > 0) { for (let i = 0; i < flags.verbose; i++) { result.push("--verbose"); } } + if (flags.config !== undefined) { result.push("--config", String(flags.config)); } + if (flags.force) { result.push("--force"); } + if (flags.confirm) { result.push("--confirm"); } + return result; + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_types.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_types.snap new file mode 100644 index 00000000..75f20974 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__full_feature_types.snap @@ -0,0 +1,64 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"types.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +export const VERSION = "1.2.3"; +/** A powerful CLI tool */ +export const ABOUT = "A powerful CLI tool"; +export const AUTHOR = "Jane Doe"; + +export type TargetChoice = "debug" | "release"; +export type EnvChoice = "staging" | "production"; + +/** Global flags available on all subcommands. */ +export interface GlobalFlags { + /** Verbosity level */ + verbose?: number; + /** Config file path. Environment variable: MYTOOL_CONFIG */ + config?: string; +} + +export interface MytoolArgs { + /** Input file */ + input: string; + /** Extra files */ + extra: string[]; +} + +export interface MytoolFlags extends GlobalFlags { + /** Verbosity level */ + verbose?: number; + /** Config file path. Environment variable: MYTOOL_CONFIG */ + config?: string; + /** Show what would be done */ + dryRun?: boolean; +} + +export interface BuildArgs { + /** Build target */ + target: TargetChoice; + /** Output directory */ + output: string; +} + +export interface BuildFlags extends GlobalFlags { + /** Parallel jobs */ + jobs?: string[]; + /** Build in release mode */ + release?: boolean; +} + +export interface DeployArgs { + /** Target environment */ + env: EnvChoice; + /** Deployment tags. Min count: 1. Max count: 5 */ + tags: string[]; +} + +export interface DeployFlags extends GlobalFlags { + /** Force deploy. @deprecated Use --confirm instead */ + force?: boolean; + /** Confirm deployment */ + confirm?: boolean; +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__hyphenated_subcommands.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__hyphenated_subcommands.snap new file mode 100644 index 00000000..9c4bf10e --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__hyphenated_subcommands.snap @@ -0,0 +1,56 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"client.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +import { CliRunner, CliResult } from "./runtime"; +import { AddRemoteArgs, RemoveRemoteArgs } from "./types"; + +export class Cli { + private runner: CliRunner; + /** Add a remote */ + readonly addRemote: AddRemote; + /** Remove a remote */ + readonly removeRemote: RemoveRemote; + + constructor(binPath?: string) { + this.runner = new CliRunner(binPath ?? "cli"); + this.addRemote = new AddRemote(this.runner); + this.removeRemote = new RemoveRemote(this.runner); + } + /** */ + exec(): CliResult { + return this.runner.run([]); + } +} + +/** Add a remote */ +export class AddRemote { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** add-remote */ + exec(args: AddRemoteArgs): CliResult { + const cmdArgs: string[] = ["add-remote"]; + if (args.name !== undefined) { cmdArgs.push(String(args.name)); } + if (args.url !== undefined) { cmdArgs.push(String(args.url)); } + return this.runner.run(cmdArgs); + } +} + +/** Remove a remote */ +export class RemoveRemote { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** remove-remote */ + exec(args: RemoveRemoteArgs): CliResult { + const cmdArgs: string[] = ["remove-remote"]; + if (args.name !== undefined) { cmdArgs.push(String(args.name)); } + return this.runner.run(cmdArgs); + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__minimal_spec.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__minimal_spec.snap new file mode 100644 index 00000000..af41cd6a --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__minimal_spec.snap @@ -0,0 +1,17 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"client.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +import { CliRunner, CliResult } from "./runtime"; + +export class Hello { + private runner: CliRunner; + + constructor(binPath?: string) { + this.runner = new CliRunner(binPath ?? "hello"); + } + exec(): CliResult { + return this.runner.run([]); + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__package_name_override.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__package_name_override.snap new file mode 100644 index 00000000..5d0b4fd4 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__package_name_override.snap @@ -0,0 +1,6 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"index.ts\")" +--- +export { MyCustomSdk } from "./client"; +export * from "./types"; diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_client.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_client.snap new file mode 100644 index 00000000..e5a3e6ad --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_client.snap @@ -0,0 +1,81 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"client.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. +import { CliRunner, CliResult } from "./runtime"; +import { Arg2Choice, InstallArgs, InstallFlags, MycliArgs, MycliFlags, ShellChoice } from "./types"; + +export class Mycli { + private runner: CliRunner; + readonly plugin: Plugin; + + constructor(binPath?: string) { + this.runner = new CliRunner(binPath ?? "mycli"); + this.plugin = new Plugin(this.runner); + } + /** [FLAGS] */ + exec(args: MycliArgs, flags?: MycliFlags): CliResult { + const cmdArgs: string[] = []; + if (args.arg1 !== undefined) { cmdArgs.push(String(args.arg1)); } + if (args.arg2 !== undefined) { cmdArgs.push(String(args.arg2)); } + if (args.arg3 !== undefined) { cmdArgs.push(String(args.arg3)); } + if (args.argrest !== undefined) { cmdArgs.push(...args.argrest); } + if (args.withDefault !== undefined) { cmdArgs.push(String(args.withDefault)); } + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: MycliFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.flag1) { result.push("--flag1"); } + if (flags.flag2) { result.push("--flag2"); } + if (flags.flag3) { result.push("--flag3"); } + else if (flags.flag3 === false) { result.push("--no-flag3"); } + if (flags.withDefault) { result.push("--with-default"); } + if (flags.shell !== undefined) { result.push("--shell", String(flags.shell)); } + return result; + } +} + +export class Plugin { + private runner: CliRunner; + readonly install: Install; + + constructor(runner: CliRunner) { + this.runner = runner; + this.install = new Install(this.runner); + } + /** plugin */ + exec(): CliResult { + return this.runner.run(["plugin"]); + } +} + +/** install a plugin */ +export class Install { + private runner: CliRunner; + + constructor(runner: CliRunner) { + this.runner = runner; + } + /** plugin install [FLAGS] */ + exec(args: InstallArgs, flags?: InstallFlags): CliResult { + const cmdArgs: string[] = ["plugin", "install"]; + if (args.plugin !== undefined) { cmdArgs.push(String(args.plugin)); } + if (args.version !== undefined) { cmdArgs.push(String(args.version)); } + const flagArgs = this.buildFlagArgs(flags); + return this.runner.run([...cmdArgs, ...flagArgs]); + } + + private buildFlagArgs(flags?: InstallFlags): string[] { + const result: string[] = []; + if (!flags) return result; + if (flags.global) { result.push("--global"); } + if (flags.dir !== undefined) { result.push("--dir", String(flags.dir)); } + if (flags.force) { result.push("--force"); } + else if (flags.force === false) { result.push("--no-force"); } + return result; + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_index.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_index.snap new file mode 100644 index 00000000..efc91d1e --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_index.snap @@ -0,0 +1,6 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"index.ts\")" +--- +export { Mycli } from "./client"; +export * from "./types"; diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_runtime.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_runtime.snap new file mode 100644 index 00000000..11304208 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_runtime.snap @@ -0,0 +1,46 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"runtime.ts\")" +--- +// Runtime module for usage-generated SDK clients. Do not edit manually. +import { execFileSync } from "node:child_process"; + +export class CliResult { + constructor( + public readonly stdout: string, + public readonly stderr: string, + public readonly exitCode: number, + ) {} + + get ok(): boolean { + return this.exitCode === 0; + } +} + +export class CliRunner { + constructor(private binPath: string) {} + + run(args: string[], flags?: Record): CliResult { + const flagArgs: string[] = []; + if (flags) { + for (const [key, value] of Object.entries(flags)) { + if (value === undefined || value === null) continue; + if (typeof value === "boolean") { + if (value) flagArgs.push(`--${key}`); + } else { + flagArgs.push(`--${key}`, String(value)); + } + } + } + + try { + const stdout = execFileSync(this.binPath, [...args, ...flagArgs], { + encoding: "utf-8", + }); + return new CliResult(stdout, "", 0); + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; status?: number }; + return new CliResult(err.stdout ?? "", err.stderr ?? "", err.status ?? 1); + } + } +} diff --git a/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_types.snap b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_types.snap new file mode 100644 index 00000000..09837b35 --- /dev/null +++ b/lib/src/sdk/typescript/snapshots/usage__sdk__typescript__types__tests__typescript_types.snap @@ -0,0 +1,42 @@ +--- +source: lib/src/sdk/typescript/types.rs +expression: "get_file(&output, \"types.ts\")" +--- +// @generated by usage-cli from test.usage.kdl. Do not edit manually. + +export type Arg2Choice = "choice1" | "choice2" | "choice3"; +export type ShellChoice = "bash" | "zsh" | "fish"; + +export interface MycliArgs { + /** arg1 description */ + arg1: string; + /** arg2 description */ + arg2?: Arg2Choice; + /** arg3 description */ + arg3: string; + argrest: string[]; + withDefault?: string; +} + +export interface MycliFlags { + /** flag1 description */ + flag1?: boolean; + /** flag2 description */ + flag2?: boolean; + /** flag3 description */ + flag3?: boolean; + /** @default default value */ + withDefault?: boolean; + shell?: ShellChoice; +} + +export interface InstallArgs { + plugin: string; + version: string; +} + +export interface InstallFlags { + global?: boolean; + dir?: string; + force?: boolean; +} diff --git a/lib/src/sdk/typescript/types.rs b/lib/src/sdk/typescript/types.rs new file mode 100644 index 00000000..bfd4c9ed --- /dev/null +++ b/lib/src/sdk/typescript/types.rs @@ -0,0 +1,501 @@ +use heck::AsPascalCase; +use indexmap::IndexMap; + +use crate::spec::cmd::SpecCommand; +use crate::spec::config::SpecConfigProp; +use crate::spec::data_types::SpecDataTypes; +use crate::{Spec, SpecArg, SpecFlag}; + +use crate::sdk::{collect_choice_types, command_type_name, generated_header, CodeWriter}; + +pub fn render(spec: &Spec, package_name: &str, source_file: &Option) -> String { + let mut w = CodeWriter::new(); + + w.line(&generated_header("//", source_file)); + + // spec metadata constants + if let Some(version) = &spec.version { + w.line(&format!("export const VERSION = \"{version}\";")); + } + if let Some(about) = &spec.about { + w.line(&format!("/** {about} */")); + w.line(&format!("export const ABOUT = \"{about}\";")); + } + if let Some(author) = &spec.author { + w.line(&format!("export const AUTHOR = \"{author}\";")); + } + + let choice_types = collect_choice_types(&spec.cmd); + + // collect root-level global flags + let root_global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + let has_global_flags = !root_global_flags.is_empty(); + + if !choice_types.is_empty() { + w.line(""); + for (name, choices) in &choice_types { + let union = choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(" | "); + w.line(&format!("export type {name} = {union};")); + } + } + + // render GlobalFlags interface if root has global flags + if has_global_flags { + w.line(""); + w.line("/** Global flags available on all subcommands. */"); + w.line("export interface GlobalFlags {"); + w.indent(); + for flag in &root_global_flags { + let prop = flag_property_name(flag); + let ts_type = flag_ts_simple(flag); + let optional = if flag.required { "" } else { "?" }; + let mut doc_parts = Vec::new(); + if let Some(help) = &flag.help { + doc_parts.push(help.clone()); + } + if let Some(env) = &flag.env { + doc_parts.push(format!("Environment variable: {env}")); + } + if !doc_parts.is_empty() { + w.line(&format!("/** {} */", doc_parts.join(". "))); + } + w.line(&format!("{prop}{optional}: {ts_type};")); + } + w.dedent(); + w.line("}"); + } + + render_command_types(&spec.cmd, package_name, &choice_types, has_global_flags, &mut w); + + if !spec.config.props.is_empty() { + w.line(""); + let config_name = format!("{}Config", AsPascalCase(package_name)); + w.line(&format!("export interface {config_name} {{")); + w.indent(); + for (name, prop) in &spec.config.props { + let ts_type = config_prop_type(prop); + let optional = if prop.default.is_some() { "?" } else { "" }; + if let Some(help) = &prop.help { + w.line(&format!("/** {help} */")); + } + w.line(&format!("{name}{optional}: {ts_type};")); + } + w.dedent(); + w.line("}"); + } + + w.to_string() +} + +fn render_command_types( + cmd: &SpecCommand, + package_name: &str, + choice_types: &IndexMap>, + has_global_flags: bool, + w: &mut CodeWriter, +) { + if cmd.hide { + return; + } + + let name = command_type_name(cmd, package_name); + + let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_any_flags = !visible_flags.is_empty() || has_global_flags; + + if !visible_args.is_empty() { + w.line(""); + w.line(&format!("export interface {name}Args {{")); + w.indent(); + for arg in &visible_args { + render_arg_field(arg, choice_types, w); + } + w.dedent(); + w.line("}"); + } + + if has_any_flags { + w.line(""); + if has_global_flags { + w.line(&format!("export interface {name}Flags extends GlobalFlags {{")); + } else { + w.line(&format!("export interface {name}Flags {{")); + } + w.indent(); + for flag in &visible_flags { + render_flag_field(flag, choice_types, w); + } + w.dedent(); + w.line("}"); + } + + for subcmd in cmd.subcommands.values() { + render_command_types(subcmd, package_name, choice_types, has_global_flags, w); + } +} + +fn render_arg_field(arg: &SpecArg, choice_types: &IndexMap>, w: &mut CodeWriter) { + let ts_type = arg_ts_type(arg, choice_types); + let optional = if arg.required && arg.default.is_none() { + "" + } else { + "?" + }; + let mut doc_parts = Vec::new(); + if let Some(help) = &arg.help { + doc_parts.push(help.clone()); + } + if let Some(env) = &arg.env { + doc_parts.push(format!("Environment variable: {env}")); + } + if arg.var { + if let Some(min) = arg.var_min { + doc_parts.push(format!("Min count: {min}")); + } + if let Some(max) = arg.var_max { + doc_parts.push(format!("Max count: {max}")); + } + } + if !doc_parts.is_empty() { + w.line(&format!("/** {} */", doc_parts.join(". "))); + } + w.line(&format!("{}{optional}: {ts_type};", sanitize_ident(&arg.name))); +} + +fn render_flag_field(flag: &SpecFlag, choice_types: &IndexMap>, w: &mut CodeWriter) { + let ts_type = flag_ts_type(flag, choice_types); + let optional = if flag.required && flag.default.is_none() { + "" + } else { + "?" + }; + let mut doc_parts = Vec::new(); + if let Some(help) = &flag.help { + doc_parts.push(help.clone()); + } + if let Some(env) = &flag.env { + doc_parts.push(format!("Environment variable: {env}")); + } + if let Some(deprecated) = &flag.deprecated { + doc_parts.push(format!("@deprecated {deprecated}")); + } + if let Some(default) = &flag.default { + doc_parts.push(format!("@default {default}")); + } + // document flag aliases + let alias_strs: Vec = flag + .short + .iter() + .skip(1) + .map(|c| format!("-{c}")) + .chain(flag.long.iter().skip(1).map(|l| format!("--{l}"))) + .collect(); + if !alias_strs.is_empty() { + doc_parts.push(format!("Aliases: {}", alias_strs.join(", "))); + } + if !doc_parts.is_empty() { + w.line(&format!("/** {} */", doc_parts.join(". "))); + } + let prop_name = flag_property_name(flag); + w.line(&format!("{prop_name}{optional}: {ts_type};")); +} + +fn arg_ts_type(arg: &SpecArg, choice_types: &IndexMap>) -> String { + let base = if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&arg.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + choices + .choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(" | ") + } + } else { + "string".to_string() + }; + + if arg.var { + format!("{base}[]") + } else { + base + } +} + +fn flag_ts_type(flag: &SpecFlag, choice_types: &IndexMap>) -> String { + if flag.count { + return "number".to_string(); + } + + match &flag.arg { + Some(arg) => { + let base = if let Some(choices) = &arg.choices { + let type_name = format!("{}Choice", AsPascalCase(&flag.name)); + if choice_types.contains_key(&type_name) { + type_name + } else { + choices + .choices + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(" | ") + } + } else { + "string".to_string() + }; + + if flag.var { + format!("{base}[]") + } else { + base + } + } + None => "boolean".to_string(), + } +} + +fn flag_ts_simple(flag: &SpecFlag) -> String { + if flag.count { + return "number".to_string(); + } + match &flag.arg { + Some(_) => { + if flag.var { + "string[]".to_string() + } else { + "string".to_string() + } + } + None => "boolean".to_string(), + } +} + +fn config_prop_type(prop: &SpecConfigProp) -> String { + match prop.data_type { + SpecDataTypes::String => "string".to_string(), + SpecDataTypes::Integer => "number".to_string(), + SpecDataTypes::Float => "number".to_string(), + SpecDataTypes::Boolean => "boolean".to_string(), + SpecDataTypes::Null => "unknown".to_string(), + } +} + +pub(crate) fn flag_property_name(flag: &SpecFlag) -> String { + if let Some(long) = flag.long.first() { + return sanitize_ident(&heck::AsLowerCamelCase(long).to_string()); + } + if let Some(short) = flag.short.first() { + return short.to_string(); + } + sanitize_ident(&flag.name) +} + +pub(crate) fn sanitize_ident(name: &str) -> String { + let camel = heck::AsLowerCamelCase(name).to_string(); + match camel.as_str() { + "function" | "class" | "const" | "let" | "var" | "type" | "interface" | "new" + | "delete" | "return" | "export" | "import" | "default" | "in" | "instanceof" => { + format!("_{camel}") + } + _ => camel, + } +} + +#[cfg(test)] +mod tests { + use crate::sdk::{SdkLanguage, SdkOptions}; + use crate::test::SPEC_KITCHEN_SINK; + use crate::Spec; + + fn make_opts() -> SdkOptions { + SdkOptions { + language: SdkLanguage::TypeScript, + package_name: None, + source_file: Some("test.usage.kdl".to_string()), + } + } + + fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str { + output + .files + .iter() + .find(|f| f.path.to_str() == Some(name)) + .unwrap_or_else(|| panic!("{name} should exist")) + .content + .as_str() + } + + #[test] + fn test_typescript_types() { + let output = super::super::super::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "types.ts")); + } + + #[test] + fn test_typescript_client() { + let output = super::super::super::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.ts")); + } + + #[test] + fn test_typescript_runtime() { + let output = super::super::super::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "runtime.ts")); + } + + #[test] + fn test_typescript_index() { + let output = super::super::super::generate(&SPEC_KITCHEN_SINK, &make_opts()); + insta::assert_snapshot!(get_file(&output, "index.ts")); + } + + /// Spec with version, about, author, global flags, double_dash, + /// deprecated, aliases, examples, repeatable value flags. + fn full_feature_spec() -> Spec { + let spec: Spec = r##" + bin "mytool" + name "mytool" + version "1.2.3" + about "A powerful CLI tool" + author "Jane Doe" + + flag "-v --verbose" help="Verbosity level" count=#true global=#true + flag "-C --config " help="Config file path" global=#true env="MYTOOL_CONFIG" + flag "--dry-run" help="Show what would be done" negate="--no-dry-run" + + arg "input" help="Input file" required=#true + arg "extra" var=#true help="Extra files" + + cmd "build" help="Build the project" deprecated="Use 'compile' instead" { + alias "b" + arg "target" help="Build target" { + choices "debug" "release" + } + arg "output" help="Output directory" double_dash="required" + flag "-j --jobs " help="Parallel jobs" var=#true + flag "--release" help="Build in release mode" + example "mytool build --release target" header="Build in release mode" lang="bash" + } + + cmd "deploy" help="Deploy the project" { + arg "env" help="Target environment" { + choices "staging" "production" + } + arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5 + flag "-f --force" help="Force deploy" deprecated="Use --confirm instead" + flag "--confirm" help="Confirm deployment" + } + "##.parse().unwrap(); + spec + } + + #[test] + fn test_full_feature_types() { + let spec = full_feature_spec(); + let output = super::super::super::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "types.ts")); + } + + #[test] + fn test_full_feature_client() { + let spec = full_feature_spec(); + let output = super::super::super::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.ts")); + } + + /// Spec with config props. + #[test] + fn test_config_props() { + let spec: Spec = r##" + bin "myapp" + config { + prop "debug" default=#true data_type=boolean help="Enable debug mode" + prop "port" default=8080 data_type=integer env="MYAPP_PORT" + prop "host" data_type=string + } + "##.parse().unwrap(); + let output = super::super::super::generate(&spec, &make_opts()); + // just verify it doesn't crash and has the config interface + let types = get_file(&output, "types.ts"); + assert!(types.contains("MyappConfig")); + assert!(types.contains("debug?: boolean")); + assert!(types.contains("port?: number")); + assert!(types.contains("host: string")); + } + + /// Spec with hyphenated subcommand names. + #[test] + fn test_hyphenated_subcommands() { + let spec: Spec = r##" + bin "cli" + cmd "add-remote" help="Add a remote" { + arg "name" + arg "url" + } + cmd "remove-remote" help="Remove a remote" { + arg "name" + } + "##.parse().unwrap(); + let output = super::super::super::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.ts")); + } + + /// Spec with deeply nested subcommands. + #[test] + fn test_deep_nesting() { + let spec: Spec = r##" + bin "app" + cmd "db" help="Database operations" { + cmd "migration" help="Migration management" { + cmd "create" help="Create a new migration" { + arg "name" + flag "--template " help="Migration template" + } + cmd "run" help="Run pending migrations" { + flag "--step " help="Number of migrations to run" + } + } + } + "##.parse().unwrap(); + let output = super::super::super::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.ts")); + } + + /// Minimal spec with no args, no flags, no subcommands. + #[test] + fn test_minimal_spec() { + let spec: Spec = r##" + bin "hello" + "##.parse().unwrap(); + let output = super::super::super::generate(&spec, &make_opts()); + insta::assert_snapshot!(get_file(&output, "client.ts")); + } + + /// Test package_name override. + #[test] + fn test_package_name_override() { + let spec: Spec = r##" + bin "original-cli" + "##.parse().unwrap(); + let opts = SdkOptions { + language: SdkLanguage::TypeScript, + package_name: Some("MyCustomSdk".to_string()), + source_file: None, + }; + let output = super::super::super::generate(&spec, &opts); + insta::assert_snapshot!(get_file(&output, "index.ts")); + } +} diff --git a/lib/src/sdk/typescript/wrappers.rs b/lib/src/sdk/typescript/wrappers.rs new file mode 100644 index 00000000..1e2001ab --- /dev/null +++ b/lib/src/sdk/typescript/wrappers.rs @@ -0,0 +1,336 @@ +use heck::AsPascalCase; + +use crate::spec::arg::SpecDoubleDashChoices; +use crate::spec::cmd::SpecCommand; +use crate::sdk::{collect_type_imports}; +use crate::{Spec, SpecArg, SpecFlag}; + +use crate::sdk::{generated_header, CodeWriter}; +use super::types::{flag_property_name, sanitize_ident}; + +pub fn render(spec: &Spec, package_name: &str, source_file: &Option) -> String { + let mut w = CodeWriter::new(); + + w.line(&generated_header("//", source_file)); + w.line("import { CliRunner, CliResult } from \"./runtime\";"); + + // collect all type imports needed + let type_imports = collect_type_imports(&spec.cmd, package_name); + let has_global_flags = spec.cmd.flags.iter().any(|f| f.global && !f.hide); + if has_global_flags { + let mut all_imports = type_imports; + all_imports.push("GlobalFlags".to_string()); + all_imports.sort(); + all_imports.dedup(); + w.line(&format!( + "import {{ {} }} from \"./types\";", + all_imports.join(", ") + )); + } else if !type_imports.is_empty() { + w.line(&format!( + "import {{ {} }} from \"./types\";", + type_imports.join(", ") + )); + } + + w.line(""); + + // collect root-level global flags for propagation to subcommands + let global_flags: Vec<&SpecFlag> = spec + .cmd + .flags + .iter() + .filter(|f| f.global && !f.hide) + .collect(); + + let class_name = AsPascalCase(package_name).to_string(); + + // render the root class (the main entry point) + render_class(&spec.cmd, &class_name, true, &global_flags, &spec.bin, &mut w); + + w.to_string() +} + +fn subcmd_path(cmd: &SpecCommand) -> String { + cmd.full_cmd + .iter() + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(", ") +} + +fn render_class( + cmd: &SpecCommand, + class_name: &str, + is_root: bool, + global_flags: &[&SpecFlag], + bin_name: &str, + w: &mut CodeWriter, +) { + let visible_subcmds: Vec<_> = cmd + .subcommands + .iter() + .filter(|(_, c)| !c.hide) + .collect(); + + let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect(); + let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect(); + let has_args = !visible_args.is_empty(); + let has_flags = !visible_flags.is_empty() || !global_flags.is_empty(); + + // JSDoc on class + let mut class_doc = Vec::new(); + if let Some(help) = &cmd.help { + class_doc.push(help.clone()); + } else if let Some(about) = &cmd.help_long { + class_doc.push(about.clone()); + } + if let Some(deprecated) = &cmd.deprecated { + class_doc.push(format!("@deprecated {deprecated}")); + } + if !cmd.aliases.is_empty() { + class_doc.push(format!("Aliases: {}", cmd.aliases.join(", "))); + } + if !class_doc.is_empty() { + w.line(&format!("/** {} */", class_doc.join("\\n"))); + } + + // class declaration + w.line(&format!("export class {class_name} {{")); + w.indent(); + + // runner field + w.line("private runner: CliRunner;"); + + // subcommand properties + for (name, subcmd) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + let prop = sanitize_ident(name); + let mut doc_parts = Vec::new(); + if let Some(help) = &subcmd.help { + doc_parts.push(help.clone()); + } + if let Some(dep) = &subcmd.deprecated { + doc_parts.push(format!("@deprecated {dep}")); + } + if !doc_parts.is_empty() { + w.line(&format!("/** {} */", doc_parts.join(". "))); + } + w.line(&format!("readonly {prop}: {sub_class};")); + } + + // alias getters for subcommand aliases + for (name, subcmd) in &visible_subcmds { + for alias in &subcmd.aliases { + let alias_prop = sanitize_ident(alias); + let target_prop = sanitize_ident(name); + let sub_class = AsPascalCase(name).to_string(); + w.line(&format!("/** Alias for `{name}` */")); + w.line(&format!("get {alias_prop}(): {sub_class} {{ return this.{target_prop}; }}")); + } + } + + // constructor + w.line(""); + if is_root { + w.line("constructor(binPath?: string) {"); + w.indent(); + w.line(&format!("this.runner = new CliRunner(binPath ?? \"{bin_name}\");")); + } else { + w.line("constructor(runner: CliRunner) {"); + w.indent(); + w.line("this.runner = runner;"); + } + for (name, _) in &visible_subcmds { + let sub_class = AsPascalCase(name).to_string(); + let prop = sanitize_ident(name); + w.line(&format!("this.{prop} = new {sub_class}(this.runner);")); + } + w.dedent(); + w.line("}"); + + // exec method + let args_param = if has_args { + format!("args: {class_name}Args") + } else { + String::new() + }; + let flags_type = if !global_flags.is_empty() && !visible_flags.is_empty() { + format!("{class_name}Flags") + } else if !global_flags.is_empty() && visible_flags.is_empty() { + "GlobalFlags".to_string() + } else if !visible_flags.is_empty() { + format!("{class_name}Flags") + } else { + String::new() + }; + let flags_param = if !flags_type.is_empty() { + if has_args { + format!(", flags?: {flags_type}") + } else { + format!("flags?: {flags_type}") + } + } else { + String::new() + }; + + // JSDoc on exec + let mut exec_doc = Vec::new(); + if !cmd.usage.is_empty() { + exec_doc.push(cmd.usage.clone()); + } + for example in &cmd.examples { + let label = example + .header + .as_deref() + .unwrap_or("Example"); + let lang = if example.lang.is_empty() { + "" + } else { + &example.lang + }; + exec_doc.push(format!("@example {label}\n```{lang}\n{code}\n```", code = example.code)); + } + if !exec_doc.is_empty() { + w.line(&format!("/** {} */", exec_doc.join("\\n"))); + } + + if has_args || has_flags { + w.line(&format!("exec({args_param}{flags_param}): CliResult {{")); + w.indent(); + + // build command args + let path = subcmd_path(cmd); + w.line(&format!("const cmdArgs: string[] = [{path}];")); + + // add positional args with double_dash handling + if has_args { + let has_required_double_dash = visible_args.iter().any(|a| { + matches!(a.double_dash, SpecDoubleDashChoices::Required) + }); + let has_automatic_double_dash = visible_args.iter().any(|a| { + matches!(a.double_dash, SpecDoubleDashChoices::Automatic) + }); + + for arg in &visible_args { + let ident = sanitize_ident(&arg.name); + if arg.var { + w.line(&format!( + "if (args.{ident} !== undefined) {{ cmdArgs.push(...args.{ident}); }}" + )); + } else { + w.line(&format!( + "if (args.{ident} !== undefined) {{ cmdArgs.push(String(args.{ident})); }}" + )); + } + } + + if has_required_double_dash { + w.line("cmdArgs.push(\"--\");"); + } else if has_automatic_double_dash { + w.line("// double_dash=automatic: \"--\" is implied after the first positional arg"); + } + } + + // add flags + if has_flags { + w.line("const flagArgs = this.buildFlagArgs(flags);"); + w.line("return this.runner.run([...cmdArgs, ...flagArgs]);"); + } else { + w.line("return this.runner.run(cmdArgs);"); + } + + w.dedent(); + w.line("}"); + + // buildFlagArgs method + if has_flags { + w.line(""); + w.line(&format!( + "private buildFlagArgs(flags?: {flags_type}): string[] {{" + )); + w.indent(); + w.line("const result: string[] = [];"); + w.line("if (!flags) return result;"); + + for flag in global_flags { + render_flag_build(flag, w); + } + for flag in &visible_flags { + if !global_flags.iter().any(|gf| gf.name == flag.name) { + render_flag_build(flag, w); + } + } + + w.line("return result;"); + w.dedent(); + w.line("}"); + } + } else { + // no args/flags: provide a simple exec + w.line("exec(): CliResult {"); + w.indent(); + let path = subcmd_path(cmd); + w.line(&format!("return this.runner.run([{path}]);")); + w.dedent(); + w.line("}"); + } + + w.dedent(); + w.line("}"); + + // render subcommand classes + for (name, subcmd) in &visible_subcmds { + w.line(""); + let sub_class = AsPascalCase(name).to_string(); + render_class(subcmd, &sub_class, false, global_flags, bin_name, w); + } +} + +fn render_flag_build(flag: &SpecFlag, w: &mut CodeWriter) { + let prop_name = flag_property_name(flag); + + // use the first long name for the flag argument, or short if no long + let flag_arg_name = if let Some(long) = flag.long.first() { + format!("--{long}") + } else if let Some(short) = flag.short.first() { + format!("-{short}") + } else { + format!("--{}", flag.name) + }; + + if flag.arg.is_some() { + if flag.var { + // repeatable value flag: --flag val1 --flag val2 + w.line(&format!( + "if (flags.{prop_name} !== undefined) {{ for (const v of flags.{prop_name}) {{ result.push(\"{flag_arg_name}\", String(v)); }} }}" + )); + } else { + // single value flag + w.line(&format!( + "if (flags.{prop_name} !== undefined) {{ result.push(\"{flag_arg_name}\", String(flags.{prop_name})); }}" + )); + } + } else if flag.count { + w.line(&format!( + "if (flags.{prop_name} !== undefined && flags.{prop_name} > 0) {{ for (let i = 0; i < flags.{prop_name}; i++) {{ result.push(\"{flag_arg_name}\"); }} }}" + )); + } else if flag.var { + // repeatable boolean flag + w.line(&format!( + "if (flags.{prop_name} !== undefined) {{ for (const v of flags.{prop_name}) {{ if (v) result.push(\"{flag_arg_name}\"); }} }}" + )); + } else { + // boolean flag + w.line(&format!( + "if (flags.{prop_name}) {{ result.push(\"{flag_arg_name}\"); }}" + )); + + // handle negate + if let Some(negate) = &flag.negate { + w.line(&format!( + "else if (flags.{prop_name} === false) {{ result.push(\"{negate}\"); }}" + )); + } + } +} diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index f5563605..56a90a42 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -5,7 +5,7 @@ pub mod cmd; pub mod complete; pub mod config; mod context; -mod data_types; +pub mod data_types; pub mod flag; pub mod helpers; pub mod mount; From 57596cea4e1d1ad19703173c7a9965b4d2e7567e Mon Sep 17 00:00:00 2001 From: gaojunran Date: Sun, 3 May 2026 18:28:43 +0800 Subject: [PATCH 02/13] fix: misc --- cli/assets/usage.1 | 25 +++++ cli/src/cli/generate/sdk.rs | 8 +- cli/usage.usage.kdl | 19 ++++ docs/cli/reference/commands.json | 105 ++++++++++++++++++ docs/cli/reference/generate.md | 1 + docs/cli/reference/generate/sdk.md | 36 ++++++ docs/cli/reference/index.md | 1 + docs/cli/sdk.md | 72 ++++++------ lib/src/lib.rs | 2 +- lib/src/sdk/mod.rs | 10 +- lib/src/sdk/python/mod.rs | 90 ++++++++++----- ...age__sdk__python__tests__python_types.snap | 6 +- lib/src/sdk/rust/client.rs | 61 ++++++---- lib/src/sdk/rust/mod.rs | 2 +- .../usage__sdk__rust__tests__rust_types.snap | 6 +- lib/src/sdk/rust/types.rs | 91 +++++++++------ lib/src/sdk/typescript/mod.rs | 4 +- lib/src/sdk/typescript/types.rs | 63 ++++++++--- lib/src/sdk/typescript/wrappers.rs | 57 ++++++---- mise.lock | 2 +- 20 files changed, 484 insertions(+), 177 deletions(-) create mode 100644 docs/cli/reference/generate/sdk.md diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 95bf8745..3de60f44 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -63,6 +63,9 @@ Generate markdown documentation from usage specs \fIAliases: \fRmd .RE .TP +\fBgenerate sdk\fR +Generate a type\-safe SDK from a usage spec +.TP \fBlint\fR Lint a usage spec file for common issues .TP @@ -282,6 +285,28 @@ Replace `
` tags with markdown code fences
 .TP
 \fB\-\-url\-prefix\fR \fI\fR
 Prefix to add to all URLs
+.SH "USAGE GENERATE SDK"
+Generate a type\-safe SDK from a usage spec
+.PP
+\fBUsage:\fR usage generate sdk [OPTIONS]
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-f, \-\-file\fR \fI\fR
+A usage spec taken in as a file
+.TP
+\fB\-l, \-\-language\fR \fI\fR
+Target language for the SDK
+.TP
+\fB\-o, \-\-output\fR \fI\fR
+Output directory for generated SDK files
+.TP
+\fB\-p, \-\-package\-name\fR \fI\fR
+Override the package/module name (defaults to spec bin name)
+.TP
+\fB\-\-spec\fR \fI\fR
+Raw string spec input
 .SH "USAGE LINT"
 Lint a usage spec file for common issues
 .PP
diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs
index 35c1fa72..2d160b2a 100644
--- a/cli/src/cli/generate/sdk.rs
+++ b/cli/src/cli/generate/sdk.rs
@@ -9,6 +9,10 @@ use usage::sdk::{SdkLanguage, SdkOptions};
 #[derive(Args)]
 #[clap(about = "Generate a type-safe SDK from a usage spec")]
 pub struct Sdk {
+    /// A usage spec taken in as a file
+    #[clap(short, long)]
+    file: Option,
+
     /// Target language for the SDK
     #[clap(short, long, value_parser = ["typescript", "python", "rust"])]
     language: String,
@@ -21,10 +25,6 @@ pub struct Sdk {
     #[clap(short, long)]
     package_name: Option,
 
-    /// A usage spec taken in as a file
-    #[clap(short, long)]
-    file: Option,
-
     /// Raw string spec input
     #[clap(long, required_unless_present = "file", overrides_with = "file")]
     spec: Option,
diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl
index be20bdd2..537ee811 100644
--- a/cli/usage.usage.kdl
+++ b/cli/usage.usage.kdl
@@ -123,6 +123,25 @@ cmd generate subcommand_required=#true help="Generate completions, documentation
             arg 
         }
     }
+    cmd sdk help="Generate a type-safe SDK from a usage spec" {
+        flag "-f --file" help="A usage spec taken in as a file" {
+            arg 
+        }
+        flag "-l --language" help="Target language for the SDK" required=#true {
+            arg  {
+                choices typescript python rust
+            }
+        }
+        flag "-o --output" help="Output directory for generated SDK files" required=#true {
+            arg 
+        }
+        flag "-p --package-name" help="Override the package/module name (defaults to spec bin name)" {
+            arg 
+        }
+        flag --spec help="Raw string spec input" {
+            arg 
+        }
+    }
 }
 cmd lint help="Lint a usage spec file for common issues" {
     flag "-f --format" help="Output format" default=text {
diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json
index 3e826e48..1b835d2e 100644
--- a/docs/cli/reference/commands.json
+++ b/docs/cli/reference/commands.json
@@ -692,6 +692,111 @@
             "aliases": ["md"],
             "hidden_aliases": [],
             "examples": []
+          },
+          "sdk": {
+            "full_cmd": ["generate", "sdk"],
+            "usage": "generate sdk ",
+            "subcommands": {},
+            "args": [],
+            "flags": [
+              {
+                "name": "file",
+                "usage": "-f --file ",
+                "help": "A usage spec taken in as a file",
+                "help_first_line": "A usage spec taken in as a file",
+                "short": ["f"],
+                "long": ["file"],
+                "hide": false,
+                "global": false,
+                "arg": {
+                  "name": "FILE",
+                  "usage": "",
+                  "required": true,
+                  "double_dash": "Optional",
+                  "hide": false
+                }
+              },
+              {
+                "name": "language",
+                "usage": "-l --language ",
+                "help": "Target language for the SDK",
+                "help_first_line": "Target language for the SDK",
+                "short": ["l"],
+                "long": ["language"],
+                "required": true,
+                "hide": false,
+                "global": false,
+                "arg": {
+                  "name": "LANGUAGE",
+                  "usage": "",
+                  "required": true,
+                  "double_dash": "Optional",
+                  "hide": false,
+                  "choices": {
+                    "choices": ["typescript", "python", "rust"]
+                  }
+                }
+              },
+              {
+                "name": "output",
+                "usage": "-o --output ",
+                "help": "Output directory for generated SDK files",
+                "help_first_line": "Output directory for generated SDK files",
+                "short": ["o"],
+                "long": ["output"],
+                "required": true,
+                "hide": false,
+                "global": false,
+                "arg": {
+                  "name": "OUTPUT",
+                  "usage": "",
+                  "required": true,
+                  "double_dash": "Optional",
+                  "hide": false
+                }
+              },
+              {
+                "name": "package-name",
+                "usage": "-p --package-name ",
+                "help": "Override the package/module name (defaults to spec bin name)",
+                "help_first_line": "Override the package/module name (defaults to spec bin name)",
+                "short": ["p"],
+                "long": ["package-name"],
+                "hide": false,
+                "global": false,
+                "arg": {
+                  "name": "PACKAGE_NAME",
+                  "usage": "",
+                  "required": true,
+                  "double_dash": "Optional",
+                  "hide": false
+                }
+              },
+              {
+                "name": "spec",
+                "usage": "--spec ",
+                "help": "Raw string spec input",
+                "help_first_line": "Raw string spec input",
+                "short": [],
+                "long": ["spec"],
+                "hide": false,
+                "global": false,
+                "arg": {
+                  "name": "SPEC",
+                  "usage": "",
+                  "required": true,
+                  "double_dash": "Optional",
+                  "hide": false
+                }
+              }
+            ],
+            "mounts": [],
+            "hide": false,
+            "help": "Generate a type-safe SDK from a usage spec",
+            "name": "sdk",
+            "aliases": [],
+            "hidden_aliases": [],
+            "examples": []
           }
         },
         "args": [],
diff --git a/docs/cli/reference/generate.md b/docs/cli/reference/generate.md
index 973b08dc..2d013092 100644
--- a/docs/cli/reference/generate.md
+++ b/docs/cli/reference/generate.md
@@ -15,3 +15,4 @@ Generate completions, documentation, and other artifacts from usage specs
 - [`usage generate json [-f --file ] [--spec ]`](/cli/reference/generate/json.md)
 - [`usage generate manpage `](/cli/reference/generate/manpage.md)
 - [`usage generate markdown `](/cli/reference/generate/markdown.md)
+- [`usage generate sdk `](/cli/reference/generate/sdk.md)
diff --git a/docs/cli/reference/generate/sdk.md b/docs/cli/reference/generate/sdk.md
new file mode 100644
index 00000000..838f2fee
--- /dev/null
+++ b/docs/cli/reference/generate/sdk.md
@@ -0,0 +1,36 @@
+
+
+# `usage generate sdk`
+
+- **Usage**: `usage generate sdk `
+- **Source code**: [`cli/src/cli/generate/sdk.rs`](https://github.com/jdx/usage/blob/main/cli/src/cli/generate/sdk.rs)
+
+Generate a type-safe SDK from a usage spec
+
+## Flags
+
+### `-f --file `
+
+A usage spec taken in as a file
+
+### `-l --language `
+
+Target language for the SDK
+
+**Choices:**
+
+- `typescript`
+- `python`
+- `rust`
+
+### `-o --output `
+
+Output directory for generated SDK files
+
+### `-p --package-name `
+
+Override the package/module name (defaults to spec bin name)
+
+### `--spec `
+
+Raw string spec input
diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md
index 3cbb7426..6c40fb70 100644
--- a/docs/cli/reference/index.md
+++ b/docs/cli/reference/index.md
@@ -32,6 +32,7 @@ Outputs a `usage.kdl` spec for this CLI itself
 - [`usage generate json [-f --file ] [--spec ]`](/cli/reference/generate/json.md)
 - [`usage generate manpage `](/cli/reference/generate/manpage.md)
 - [`usage generate markdown `](/cli/reference/generate/markdown.md)
+- [`usage generate sdk `](/cli/reference/generate/sdk.md)
 - [`usage lint [-f --format ] [-W --warnings-as-errors] `](/cli/reference/lint.md)
 - [`usage powershell [-h] [--help]