From 08457e7517499b59838b7d66f40e475ddfc9a8d3 Mon Sep 17 00:00:00 2001 From: Miguel Oliveira Date: Mon, 4 Nov 2024 18:29:01 +0000 Subject: [PATCH] Add fig generate completion subcommand (#148) * Add fig generate completion subcommand * Fix help text on spec option * Ignore generators.ts file as it is used as template only for a different tool * Address comments * Lint fix * fix: lint * fix: lint --------- Co-authored-by: jdx <216188+jdx@users.noreply.github.com> --- .codacy.yml | 3 + .prettierignore | 1 + Cargo.lock | 1 + cli/Cargo.toml | 1 + cli/src/cli/generate/fig.rs | 384 ++++++++++++++++++++++++++++++++++++ cli/src/cli/generate/mod.rs | 3 + cli/usage.usage.kdl | 12 ++ docs/cli/reference.md | 25 ++- tasks/fig/generators.ts | 45 +++++ 9 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 .codacy.yml create mode 100644 cli/src/cli/generate/fig.rs create mode 100644 tasks/fig/generators.ts diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..52a0697 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,3 @@ +--- +exclude_paths: + - "tasks/fig/generators.ts" diff --git a/.prettierignore b/.prettierignore index 7923835..6e2cd6b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ CHANGELOG.md docs/.vitepress +docs/cli/reference.md docs/public/site.webmanifest examples/docs lefthook.yml diff --git a/Cargo.lock b/Cargo.lock index 257ccfa..e4ced7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1486,6 +1486,7 @@ dependencies = [ "predicates", "regex", "serde", + "serde_json", "strum", "tera", "thiserror", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f642997..b53e35e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -38,6 +38,7 @@ miette = { version = "5", features = ["fancy"] } once_cell = "1" regex = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1.0" strum = { version = "0.26", features = ["derive"] } tera = "1" thiserror = "1" diff --git a/cli/src/cli/generate/fig.rs b/cli/src/cli/generate/fig.rs new file mode 100644 index 0000000..d22ed88 --- /dev/null +++ b/cli/src/cli/generate/fig.rs @@ -0,0 +1,384 @@ +use std::path::PathBuf; +use std::vec; + +use clap::Args; +use indexmap::IndexMap; +use itertools::Itertools; +use usage::{SpecArg, SpecCommand, SpecComplete, SpecFlag}; + +use crate::cli::generate; +use serde::{Deserialize, Serialize, Serializer}; + +#[derive(Args)] +#[clap()] +pub struct Fig { + /// 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, + + /// File on where to save the generated Fig spec + #[clap(long, value_hint = clap::ValueHint::FilePath)] + out_file: Option, + + /// Whether to output to stdout + #[clap(long, action = clap::ArgAction::SetTrue, required_unless_present="out_file", overrides_with = "out_file")] + stdout: Option, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +enum GeneratorType { + EnvVar, + Complete, +} + +#[derive(Deserialize, Clone)] +struct FigGenerator { + type_: GeneratorType, + post_process: String, + template_str: String, +} + +impl Serialize for FigGenerator { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.template_str) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct FigArg { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + is_optional: bool, + is_variadic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + template: Option, + #[serde(skip_serializing_if = "Option::is_none")] + generators: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + suggestions: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +struct FigOption { + name: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(rename(serialize = "isRepeatable"))] + is_repeatable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct FigCommand { + name: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + subcommands: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + options: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + args: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + generate_spec: Option, +} + +impl FigGenerator { + pub fn create_simple_generator(type_: GeneratorType) -> Self { + Self { + type_: type_.clone(), + template_str: FigGenerator::get_generator_name(type_).to_uppercase(), + post_process: "".to_string(), + } + } + + fn get_generator_name(type_: GeneratorType) -> String { + match type_.clone() { + GeneratorType::EnvVar => "envVarGenerator".to_string(), + GeneratorType::Complete => "completionGeneratorTemplate".to_string(), + } + } + + fn get_generator_arg(&self) -> String { + match self.type_ { + GeneratorType::Complete => { + let postprocess = self.post_process.clone(); + format!("(`{postprocess}`)") + } + _ => "".to_string(), + } + } + + pub fn get_generator_text(&self) -> String { + let generator_name = FigGenerator::get_generator_name(self.type_.clone()); + let arg = self.get_generator_arg(); + + format!("{generator_name}{arg}") + } +} + +impl FigArg { + fn get_template(name: &str) -> Option { + name.to_lowercase() + .contains("file") + .then(|| "filepaths".to_string()) + .or(name + .to_lowercase() + .contains("dir") + .then(|| "folders".to_string())) + .or(name + .to_lowercase() + .contains("path") + .then(|| "filepaths".to_string())) + } + + fn get_generator(name: &str) -> Option { + name.to_lowercase() + .contains("env_vars") + .then(|| FigGenerator::create_simple_generator(GeneratorType::EnvVar)) + .or(name + .to_lowercase() + .contains("env_var") + .then(|| FigGenerator::create_simple_generator(GeneratorType::EnvVar))) + } + + pub fn get_generators(&self) -> Vec { + match self.generators.clone() { + Some(a) => vec![a], + None => vec![], + } + } + + fn get_name(name: &str) -> String { + name.replace("<", "") + .replace(">", "") + .replace("[", "") + .replace("]", "") + .to_ascii_lowercase() + } + + pub fn parse_from_spec(arg: &SpecArg) -> Self { + Self { + name: FigArg::get_name(&arg.name), + description: arg.help.clone(), + is_variadic: arg.var, + is_optional: !arg.required, + template: FigArg::get_template(&arg.name), + generators: FigArg::get_generator(&arg.name), + suggestions: arg.choices.clone().map(|c| c.choices).unwrap_or_default(), + } + } + + pub fn update_from_complete(&mut self, spec: SpecComplete) { + let name = spec.name; + + self.generators = self.generators.clone().or_else(|| { + Some(FigGenerator { + type_: GeneratorType::Complete, + post_process: spec.run.unwrap_or("".to_string()), + template_str: format!("${name}$"), + }) + }) + } +} + +impl FigOption { + fn get_names(flag: &SpecFlag) -> Vec { + let mut n: Vec = flag.short.iter().map(|c| format!("-{c}")).collect(); + n.extend(flag.long.iter().map(|l| format!("--{l}"))); + n + } + + pub fn get_generators(&self) -> Vec { + self.args + .iter() + .filter(|&a| a.generators.is_some()) + .cloned() + .map(|a| a.generators.unwrap()) + .collect() + } + + pub fn get_args(&mut self) -> Vec<&mut FigArg> { + self.args.as_mut().map(|a| vec![a]).unwrap_or_default() + } + + pub fn parse_from_spec(flag: &SpecFlag) -> Self { + Self { + name: FigOption::get_names(flag), + description: flag.help.clone(), + is_repeatable: flag.var, + args: flag.arg.clone().map(|arg| FigArg::parse_from_spec(&arg)), + } + } +} +impl FigCommand { + fn get_names(cmd: &SpecCommand) -> Vec { + let mut r = vec![cmd.name.clone()]; + r.extend(cmd.aliases.clone()); + r + } + + pub fn get_generators(&self) -> Vec { + let sub = self + .subcommands + .iter() + .map(|s| s.get_generators()) + .collect_vec() + .concat(); + let opt = self + .options + .iter() + .map(|o| o.get_generators()) + .collect_vec() + .concat(); + let args = self + .args + .iter() + .map(|a| a.get_generators()) + .collect_vec() + .concat(); + [sub, opt, args].concat() + } + + pub fn get_commands(&self) -> Vec { + let subcmds = self.subcommands.iter().map(|s| s.get_commands()).concat(); + [subcmds, vec![self.clone()]].concat() + } + + pub fn get_args(&mut self) -> Vec<&mut FigArg> { + let opt_args = self.options.iter_mut().map(|o| o.get_args()).concat(); + let sub_args = self.subcommands.iter_mut().map(|c| c.get_args()).concat(); + + let args = self.args.iter_mut().collect_vec(); + let mut result = Vec::new(); + for vec in [opt_args, sub_args, args] { + result.extend(vec); + } + result + } + + pub fn parse_from_spec(cmd: &SpecCommand) -> Option { + (!cmd.hide).then(|| Self { + name: FigCommand::get_names(cmd), + description: cmd.help.clone(), + subcommands: cmd + .subcommands + .iter() + .filter(|(_, v)| !v.hide) + .filter_map(|(_, v)| FigCommand::parse_from_spec(v)) + .collect(), + options: cmd + .flags + .iter() + .filter(|f| !f.hide) + .map(FigOption::parse_from_spec) + .collect(), + args: cmd + .args + .iter() + .filter(|a| !a.hide) + .map(FigArg::parse_from_spec) + .collect(), + generate_spec: (!cmd.mounts.is_empty()).then(|| { + let calls = cmd + .mounts + .iter() + .cloned() + .map(|m| { + let run = m.run; + format!("\"{run}\"") + }) + .join(","); + format!("${calls}$") + }), + }) + } +} + +impl Fig { + pub fn run(&self) -> miette::Result<()> { + let write = |path: &PathBuf, md: &str| -> miette::Result<()> { + println!("writing to {}", path.display()); + xx::file::write(path, format!("{}\n", md.trim()))?; + Ok(()) + }; + let spec = generate::file_or_spec(&self.file, &self.spec)?; + let mut main_command = FigCommand::parse_from_spec(&spec.cmd).unwrap(); + let args = main_command.get_args(); + let completes = spec.complete; + Fig::fill_args_complete(args, completes); + let j = serde_json::to_string_pretty(&main_command).unwrap(); + let path = self.out_file.clone().unwrap_or(PathBuf::from("./usage.ts")); + let mut result = format!("const completionSpec: Fig.Spec = {j}"); + + let generators = main_command.get_generators(); + generators.iter().cloned().for_each(|g| { + let template_str = g.clone().template_str; + let generator_call_text = g.get_generator_text(); + result = result.replace( + format!("\"{template_str}\"").as_str(), + generator_call_text.as_str(), + ) + }); + + // Handle mount run commands + main_command + .get_commands() + .iter() + .filter(|&cmd| cmd.generate_spec.is_some()) + .cloned() + .for_each(|cmd| { + let call_template_str = cmd.generate_spec.unwrap(); + let args = call_template_str.replace("$", ""); + let replace_str = call_template_str.replace("\"", "\\\""); + result = result.replace( + format!("\"{replace_str}\"").as_str(), + format!("usageGenerateSpec([{args}])").as_str(), + ) + }); + + let output_to_str = self.stdout.unwrap_or(true); + if output_to_str { + print!("{result}"); + Ok(()) + } else { + result = [Fig::get_prescript(), result, Fig::get_postscript()].join("\n\n"); + write(&path, result.as_str()) + } + } + + fn get_prescript() -> String { + include_str!("../../../../tasks/fig/generators.ts").to_string() + } + + fn get_postscript() -> String { + "export default completionSpec;".to_string() + } + + fn fill_args_complete(args: Vec<&mut FigArg>, completes: IndexMap) { + let completable_args = args + .into_iter() + .map(|a| { + let completekv = completes.get_key_value(&a.name); + completekv.map(|(_, v)| (a, v.clone())) + }) + .filter(Option::is_some); + + completable_args.for_each(|a| { + let x = a.unwrap(); + x.0.update_from_complete(x.1) + }); + } +} diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 5245fc5..54673f9 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -4,6 +4,7 @@ use usage::error::UsageErr; use usage::Spec; mod completion; +mod fig; mod markdown; #[derive(clap::Args)] @@ -16,6 +17,7 @@ pub struct Generate { #[derive(clap::Subcommand)] pub enum Command { Completion(completion::Completion), + Fig(fig::Fig), Markdown(markdown::Markdown), } @@ -24,6 +26,7 @@ impl Generate { match &self.command { Command::Completion(cmd) => cmd.run(), Command::Markdown(cmd) => cmd.run(), + Command::Fig(cmd) => cmd.run(), } } } diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 48d4b68..b2481c3 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -54,6 +54,18 @@ cmd "generate" subcommand_required=true { } arg "" help="The CLI which we're generates completions for" } + cmd "fig" { + flag "-f --file" help="A usage spec taken in as a file" { + arg "" + } + flag "--spec" help="raw string spec input" { + arg "" + } + flag "--out-file" help="File on where to save the generated Fig spec" { + arg "" + } + flag "--stdout" help="Whether to output to stdout" + } cmd "markdown" { alias "md" flag "-f --file" help="A usage spec taken in as a file" required=true { diff --git a/docs/cli/reference.md b/docs/cli/reference.md index d4a5de6..7ec84fa 100644 --- a/docs/cli/reference.md +++ b/docs/cli/reference.md @@ -1,9 +1,9 @@ # `usage` - - **version**: 1.0.1 CLI for working with usage-based CLIs + - **Usage**: `usage [--usage-spec] [COMPLETIONS] ` ## Arguments @@ -133,6 +133,29 @@ A command which generates a usage spec e.g.: `mycli --usage` or `mycli completio #### `-f --file ` +## `usage generate fig` + +- **Usage**: `usage generate fig [FLAGS]` +- **Source code**: [`cli/src/cli/generate/fig.rs`](https://github.com/jdx/usage/blob/main/cli/src/cli/generate/fig.rs) + +### Flags + +#### `-f --file ` + +A usage spec taken in as a file + +#### `--spec ` + +raw string spec input + +#### `--out-file ` + +File on where to save the generated Fig spec + +#### `--stdout` + +Whether to output to stdout + ## `usage generate markdown` - **Usage**: `usage generate markdown ` diff --git a/tasks/fig/generators.ts b/tasks/fig/generators.ts new file mode 100644 index 0000000..d5af688 --- /dev/null +++ b/tasks/fig/generators.ts @@ -0,0 +1,45 @@ +const usageGeneratorTemplate = (usage_cmd: string): Fig.Generator => { + return { + custom: async (tokens: string[], executeCommand) => { + const { stdout: spec } = await executeCommand({ + command: "sh", + args: ["-c", usage_cmd], + }); + + const { stdout: completes } = await executeCommand({ + command: "usage", + args: ["complete-word", "--shell", "bash", "-s", spec], + }); + + return completes + .split("\n") + .map((l) => ({ name: l.trim(), type: "special" })); + }, + }; +}; + +const completionGeneratorTemplate = ( + argSuggestionBash: string, +): Fig.Generator => { + return { + custom: async (tokens: string[], executeCommand) => { + let arg = argSuggestionBash; + if (tokens.length >= 1) { + arg = argSuggestionBash.replace( + "{{words[CURRENT]}}", + tokens[tokens.length - 1], + ); + } + + if (tokens.length >= 2) { + arg = arg.replace(`{{words[PREV]}}`, tokens[tokens.length - 2]); + } + const { stdout: text } = await executeCommand({ + command: "sh", + args: ["-c", arg], + }); + if (text.trim().length == 0) return []; + return text.split("\n").map((elm) => ({ name: elm })); + }, + }; +};