1use std::fmt;
2
3use std::path::PathBuf;
4
5use heck::AsPascalCase;
6use indexmap::IndexMap;
7
8use crate::spec::cmd::SpecCommand;
9use crate::Spec;
10
11pub mod python;
12pub mod typescript;
13
14#[derive(Debug, Clone)]
15pub enum SdkLanguage {
16 TypeScript,
17 Python,
18}
19
20#[derive(Debug, Clone)]
21pub struct SdkOptions {
22 pub language: SdkLanguage,
23 pub package_name: Option<String>,
24 pub source_file: Option<String>,
25}
26
27#[derive(Debug)]
28pub struct SdkOutput {
29 pub files: Vec<SdkFile>,
30}
31
32#[derive(Debug)]
33pub struct SdkFile {
34 pub path: PathBuf,
35 pub content: String,
36}
37
38pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput {
39 match opts.language {
40 SdkLanguage::TypeScript => typescript::generate(spec, opts),
41 SdkLanguage::Python => python::generate(spec, opts),
42 }
43}
44
45pub(crate) fn escape_jsdoc(s: &str) -> String {
47 s.replace("*/", r"*\/")
48}
49
50pub(crate) fn escape_py_docstring(s: &str) -> String {
52 s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
53}
54
55fn escape_string_literal(s: &str) -> String {
64 let mut out = String::with_capacity(s.len());
65 for c in s.chars() {
66 match c {
67 '\\' => out.push_str(r"\\"),
68 '"' => out.push_str(r#"\""#),
69 '\n' => out.push_str(r"\n"),
70 '\r' => out.push_str(r"\r"),
71 '\t' => out.push_str(r"\t"),
72 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
73 c => out.push(c),
74 }
75 }
76 out
77}
78
79pub(crate) fn escape_py_string(s: &str) -> String {
81 escape_string_literal(s)
82}
83
84pub(crate) fn escape_ts_string(s: &str) -> String {
86 escape_string_literal(s)
87}
88
89pub(crate) struct CodeWriter {
91 buf: String,
92 indent: usize,
93 indent_str: &'static str,
94}
95
96impl CodeWriter {
97 pub fn new() -> Self {
98 Self {
99 buf: String::new(),
100 indent: 0,
101 indent_str: " ",
102 }
103 }
104
105 pub fn with_indent(indent_str: &'static str) -> Self {
107 Self {
108 buf: String::new(),
109 indent: 0,
110 indent_str,
111 }
112 }
113
114 pub fn line(&mut self, s: &str) {
115 if !s.is_empty() {
116 for _ in 0..self.indent {
117 self.buf.push_str(self.indent_str);
118 }
119 }
120 self.buf.push_str(s);
121 self.buf.push('\n');
122 }
123
124 pub fn indent(&mut self) {
125 self.indent += 1;
126 }
127
128 pub fn dedent(&mut self) {
129 self.indent = self.indent.saturating_sub(1);
130 }
131
132 pub fn finish(self) -> String {
133 self.buf
134 }
135}
136
137impl fmt::Display for CodeWriter {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 write!(f, "{}", self.buf)
140 }
141}
142
143pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> String {
144 match source {
145 Some(s) => {
146 format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.")
147 }
148 None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."),
149 }
150}
151
152pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String {
154 if cmd.name.is_empty() {
155 AsPascalCase(package_name).to_string()
156 } else {
157 AsPascalCase(&cmd.name).to_string()
158 }
159}
160
161pub(crate) struct ChoiceTypeMap {
170 pub types: IndexMap<String, Vec<String>>,
172 name_map: IndexMap<(String, String), String>,
174}
175
176impl ChoiceTypeMap {
177 pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
181 self.name_map
182 .get(&(cmd_name.to_string(), item_name.to_string()))
183 .map(|s| s.as_str())
184 }
185
186 pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
188 self.types.iter()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.types.is_empty()
194 }
195}
196
197struct ChoiceEntry {
198 base_name: String,
199 item_name: String,
200 cmd_name: String,
201 cmd_prefix: String,
202 choices: Vec<String>,
203}
204
205pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
209 let mut all_entries: Vec<ChoiceEntry> = Vec::new();
210 collect_choice_entries(cmd, &mut all_entries);
211
212 let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
214 for entry in &all_entries {
215 base_groups
216 .entry(entry.base_name.clone())
217 .or_default()
218 .push(entry);
219 }
220
221 let mut types = IndexMap::new();
222 let mut name_map = IndexMap::new();
223 for (base_name, entries) in &base_groups {
224 let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
225 if all_same {
226 types.insert(base_name.clone(), entries[0].choices.clone());
227 for entry in entries {
228 name_map.insert(
229 (entry.cmd_name.clone(), entry.item_name.clone()),
230 base_name.clone(),
231 );
232 }
233 } else {
234 for entry in entries {
235 let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
236 types.insert(prefixed.clone(), entry.choices.clone());
237 name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
238 }
239 }
240 }
241
242 ChoiceTypeMap { types, name_map }
243}
244
245fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
246 if cmd.hide {
247 return;
248 }
249
250 let cmd_prefix = if cmd.name.is_empty() {
251 String::new()
252 } else {
253 AsPascalCase(&cmd.name).to_string()
254 };
255 let cmd_name = cmd.name.clone();
256
257 for arg in &cmd.args {
258 if arg.hide {
259 continue;
260 }
261 if let Some(choices) = &arg.choices {
262 let base_name = format!("{}Choice", AsPascalCase(&arg.name));
263 entries.push(ChoiceEntry {
264 base_name,
265 item_name: arg.name.clone(),
266 cmd_name: cmd_name.clone(),
267 cmd_prefix: cmd_prefix.clone(),
268 choices: choices.choices.clone(),
269 });
270 }
271 }
272
273 for flag in &cmd.flags {
274 if flag.hide {
275 continue;
276 }
277 if let Some(arg) = &flag.arg {
278 if let Some(choices) = &arg.choices {
279 let base_name = format!("{}Choice", AsPascalCase(&flag.name));
280 entries.push(ChoiceEntry {
281 base_name,
282 item_name: flag.name.clone(),
283 cmd_name: cmd_name.clone(),
284 cmd_prefix: cmd_prefix.clone(),
285 choices: choices.choices.clone(),
286 });
287 }
288 }
289 }
290
291 for subcmd in cmd.subcommands.values() {
292 collect_choice_entries(subcmd, entries);
293 }
294}
295
296pub(crate) fn collect_type_imports(
298 cmd: &SpecCommand,
299 package_name: &str,
300 choice_types: &ChoiceTypeMap,
301) -> Vec<String> {
302 let mut imports = Vec::new();
303 collect_type_imports_recursive(cmd, package_name, choice_types, &mut imports);
304 imports.sort();
305 imports.dedup();
306 imports
307}
308
309fn collect_type_imports_recursive(
310 cmd: &SpecCommand,
311 package_name: &str,
312 choice_types: &ChoiceTypeMap,
313 imports: &mut Vec<String>,
314) {
315 if cmd.hide {
316 return;
317 }
318
319 let name = command_type_name(cmd, package_name);
320 let has_args = cmd.args.iter().any(|a| !a.hide);
321 let has_flags = cmd.flags.iter().any(|f| !f.hide);
322
323 if has_args {
324 imports.push(format!("{name}Args"));
325 }
326 if has_flags {
327 imports.push(format!("{name}Flags"));
328 }
329
330 for arg in &cmd.args {
331 if !arg.hide && arg.choices.is_some() {
332 if let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name) {
333 imports.push(type_name.to_string());
334 }
335 }
336 }
337 for flag in &cmd.flags {
338 if !flag.hide {
339 if let Some(arg) = &flag.arg {
340 if arg.choices.is_some() {
341 if let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name) {
342 imports.push(type_name.to_string());
343 }
344 }
345 }
346 }
347 }
348
349 for subcmd in cmd.subcommands.values() {
350 collect_type_imports_recursive(subcmd, package_name, choice_types, imports);
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_code_writer_display() {
360 let mut w = CodeWriter::with_indent(" ");
361 w.line("hello");
362 w.line("world");
363 let displayed = format!("{w}");
364 assert!(displayed.contains("hello"));
365 assert!(displayed.contains("world"));
366 }
367
368 #[test]
369 fn test_command_type_name_empty() {
370 let cmd = SpecCommand::default();
371 assert!(cmd.name.is_empty());
372 let result = command_type_name(&cmd, "mypackage");
373 assert_eq!(result, "Mypackage");
374 }
375
376 #[test]
377 fn test_generated_header_with_source() {
378 let result = generated_header("//", &Some("test.kdl".to_string()));
379 assert!(result.contains("test.kdl"));
380 }
381
382 #[test]
383 fn test_generated_header_without_source() {
384 let result = generated_header("//", &None);
385 assert!(!result.contains("test.kdl"));
386 assert!(result.contains("@generated"));
387 }
388
389 #[test]
392 fn test_hidden_command_with_choices() {
393 let spec: crate::Spec = r##"
394 bin "app"
395 cmd "visible" help="Visible" {
396 arg "env" help="Environment" {
397 choices "dev" "prod"
398 }
399 }
400 cmd "hidden" hide=#true help="Hidden" {
401 arg "mode" help="Mode" {
402 choices "fast" "slow"
403 }
404 flag "--level <n>" help="Level" {
405 choices "1" "2" "3"
406 }
407 }
408 "##
409 .parse()
410 .unwrap();
411 let choice_types = collect_choice_types(&spec.cmd);
412 assert!(choice_types.lookup("hidden", "mode").is_none());
414 assert!(choice_types.lookup("hidden", "level").is_none());
415 assert!(choice_types.lookup("visible", "env").is_some());
417 }
418
419 #[test]
421 fn test_hidden_arg_flag_with_choices() {
422 let spec: crate::Spec = r##"
423 bin "app"
424 arg "visible_choice" help="Visible" {
425 choices "a" "b"
426 }
427 arg "hidden_choice" hide=#true help="Hidden" {
428 choices "x" "y"
429 }
430 flag "--visible-flag <val>" help="Visible" {
431 choices "m" "n"
432 }
433 flag "--hidden-flag <val>" hide=#true help="Hidden" {
434 choices "p" "q"
435 }
436 "##
437 .parse()
438 .unwrap();
439 let choice_types = collect_choice_types(&spec.cmd);
440 assert!(choice_types.lookup("app", "visible_choice").is_some());
441 assert!(choice_types.lookup("app", "hidden_choice").is_none());
442 assert!(choice_types.lookup("app", "visible-flag").is_some());
443 assert!(choice_types.lookup("app", "hidden-flag").is_none());
444 }
445
446 #[test]
448 fn test_flag_arg_choices_import() {
449 let spec: crate::Spec = r##"
450 bin "app"
451 flag "--shell <shell>" help="Shell type" {
452 choices "bash" "zsh" "fish"
453 }
454 "##
455 .parse()
456 .unwrap();
457 let choice_types = collect_choice_types(&spec.cmd);
458 let mut imports = Vec::new();
459 collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &mut imports);
460 assert!(imports.iter().any(|i| i.contains("Choice")));
461 }
462}