use std::fmt;
use std::path::PathBuf;
use heck::AsPascalCase;
use indexmap::IndexMap;
use crate::spec::cmd::SpecCommand;
use crate::Spec;
pub mod python;
pub mod typescript;
#[derive(Debug, Clone)]
pub enum SdkLanguage {
TypeScript,
Python,
}
#[derive(Debug, Clone)]
pub struct SdkOptions {
pub language: SdkLanguage,
pub package_name: Option<String>,
pub source_file: Option<String>,
}
#[derive(Debug)]
pub struct SdkOutput {
pub files: Vec<SdkFile>,
}
#[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),
}
}
pub(crate) fn escape_jsdoc(s: &str) -> String {
s.replace("*/", r"*\/")
}
pub(crate) fn escape_py_docstring(s: &str) -> String {
s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
}
pub(crate) fn escape_py_string(s: &str) -> String {
s.replace('\\', r"\\").replace('"', r#"\""#)
}
pub(crate) fn escape_ts_string(s: &str) -> String {
s.replace('\\', r"\\").replace('"', r#"\""#)
}
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: " ",
}
}
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 finish(self) -> String {
self.buf
}
}
impl fmt::Display for CodeWriter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.buf)
}
}
pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> 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."),
}
}
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()
}
}
pub(crate) struct ChoiceTypeMap {
pub types: IndexMap<String, Vec<String>>,
name_map: IndexMap<(String, String), String>,
}
impl ChoiceTypeMap {
pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
self.name_map
.get(&(cmd_name.to_string(), item_name.to_string()))
.map(|s| s.as_str())
}
pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
self.types.iter()
}
pub fn is_empty(&self) -> bool {
self.types.is_empty()
}
}
struct ChoiceEntry {
base_name: String,
item_name: String,
cmd_name: String,
cmd_prefix: String,
choices: Vec<String>,
}
pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
let mut all_entries: Vec<ChoiceEntry> = Vec::new();
collect_choice_entries(cmd, &mut all_entries);
let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
for entry in &all_entries {
base_groups
.entry(entry.base_name.clone())
.or_default()
.push(entry);
}
let mut types = IndexMap::new();
let mut name_map = IndexMap::new();
for (base_name, entries) in &base_groups {
let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
if all_same {
types.insert(base_name.clone(), entries[0].choices.clone());
for entry in entries {
name_map.insert(
(entry.cmd_name.clone(), entry.item_name.clone()),
base_name.clone(),
);
}
} else {
for entry in entries {
let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
types.insert(prefixed.clone(), entry.choices.clone());
name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
}
}
}
ChoiceTypeMap { types, name_map }
}
fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
if cmd.hide {
return;
}
let cmd_prefix = if cmd.name.is_empty() {
String::new()
} else {
AsPascalCase(&cmd.name).to_string()
};
let cmd_name = cmd.name.clone();
for arg in &cmd.args {
if arg.hide {
continue;
}
if let Some(choices) = &arg.choices {
let base_name = format!("{}Choice", AsPascalCase(&arg.name));
entries.push(ChoiceEntry {
base_name,
item_name: arg.name.clone(),
cmd_name: cmd_name.clone(),
cmd_prefix: cmd_prefix.clone(),
choices: 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 base_name = format!("{}Choice", AsPascalCase(&flag.name));
entries.push(ChoiceEntry {
base_name,
item_name: flag.name.clone(),
cmd_name: cmd_name.clone(),
cmd_prefix: cmd_prefix.clone(),
choices: choices.choices.clone(),
});
}
}
}
for subcmd in cmd.subcommands.values() {
collect_choice_entries(subcmd, entries);
}
}
pub(crate) fn collect_type_imports(
cmd: &SpecCommand,
package_name: &str,
choice_types: &ChoiceTypeMap,
) -> Vec<String> {
let mut imports = Vec::new();
collect_type_imports_recursive(cmd, package_name, choice_types, &mut imports);
imports.sort();
imports.dedup();
imports
}
fn collect_type_imports_recursive(
cmd: &SpecCommand,
package_name: &str,
choice_types: &ChoiceTypeMap,
imports: &mut Vec<String>,
) {
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() {
if let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name) {
imports.push(type_name.to_string());
}
}
}
for flag in &cmd.flags {
if !flag.hide {
if let Some(arg) = &flag.arg {
if arg.choices.is_some() {
if let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name) {
imports.push(type_name.to_string());
}
}
}
}
}
for subcmd in cmd.subcommands.values() {
collect_type_imports_recursive(subcmd, package_name, choice_types, imports);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_code_writer_display() {
let mut w = CodeWriter::with_indent(" ");
w.line("hello");
w.line("world");
let displayed = format!("{w}");
assert!(displayed.contains("hello"));
assert!(displayed.contains("world"));
}
#[test]
fn test_command_type_name_empty() {
let cmd = SpecCommand::default();
assert!(cmd.name.is_empty());
let result = command_type_name(&cmd, "mypackage");
assert_eq!(result, "Mypackage");
}
#[test]
fn test_generated_header_with_source() {
let result = generated_header("//", &Some("test.kdl".to_string()));
assert!(result.contains("test.kdl"));
}
#[test]
fn test_generated_header_without_source() {
let result = generated_header("//", &None);
assert!(!result.contains("test.kdl"));
assert!(result.contains("@generated"));
}
#[test]
fn test_hidden_command_with_choices() {
let spec: crate::Spec = r##"
bin "app"
cmd "visible" help="Visible" {
arg "env" help="Environment" {
choices "dev" "prod"
}
}
cmd "hidden" hide=#true help="Hidden" {
arg "mode" help="Mode" {
choices "fast" "slow"
}
flag "--level <n>" help="Level" {
choices "1" "2" "3"
}
}
"##
.parse()
.unwrap();
let choice_types = collect_choice_types(&spec.cmd);
assert!(choice_types.lookup("hidden", "mode").is_none());
assert!(choice_types.lookup("hidden", "level").is_none());
assert!(choice_types.lookup("visible", "env").is_some());
}
#[test]
fn test_hidden_arg_flag_with_choices() {
let spec: crate::Spec = r##"
bin "app"
arg "visible_choice" help="Visible" {
choices "a" "b"
}
arg "hidden_choice" hide=#true help="Hidden" {
choices "x" "y"
}
flag "--visible-flag <val>" help="Visible" {
choices "m" "n"
}
flag "--hidden-flag <val>" hide=#true help="Hidden" {
choices "p" "q"
}
"##
.parse()
.unwrap();
let choice_types = collect_choice_types(&spec.cmd);
assert!(choice_types.lookup("app", "visible_choice").is_some());
assert!(choice_types.lookup("app", "hidden_choice").is_none());
assert!(choice_types.lookup("app", "visible-flag").is_some());
assert!(choice_types.lookup("app", "hidden-flag").is_none());
}
#[test]
fn test_flag_arg_choices_import() {
let spec: crate::Spec = r##"
bin "app"
flag "--shell <shell>" help="Shell type" {
choices "bash" "zsh" "fish"
}
"##
.parse()
.unwrap();
let choice_types = collect_choice_types(&spec.cmd);
let mut imports = Vec::new();
collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &mut imports);
assert!(imports.iter().any(|i| i.contains("Choice")));
}
}