diff --git a/src/commands/build.rs b/src/commands/build.rs index 16270640e..f0272908d 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -22,6 +22,7 @@ pub struct BuildCommand { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::AnyPath, )] pub app_source: Option, diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index d825b3678..17b8e7ac1 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -17,7 +17,8 @@ pub struct DoctorCommand { name = APP_MANIFEST_FILE_OPT, short = 'f', long = "from", - alias = "file" + alias = "file", + value_hint = clap::ValueHint::AnyPath, )] pub app_source: Option, } diff --git a/src/commands/maintenance.rs b/src/commands/maintenance.rs index 0fbde7e92..dcacc315d 100644 --- a/src/commands/maintenance.rs +++ b/src/commands/maintenance.rs @@ -9,6 +9,8 @@ pub enum MaintenanceCommands { GenerateReference(GenerateReference), /// Generate JSON schema for application manifest. GenerateManifestSchema(GenerateSchema), + /// Generate a `completely` file which can then be processed into shell completions. + GenerateShellCompletions(GenerateCompletions), } impl MaintenanceCommands { @@ -16,6 +18,7 @@ impl MaintenanceCommands { match self { MaintenanceCommands::GenerateReference(cmd) => cmd.run(app).await, MaintenanceCommands::GenerateManifestSchema(cmd) => cmd.run().await, + MaintenanceCommands::GenerateShellCompletions(cmd) => cmd.run(app).await, } } } @@ -58,3 +61,141 @@ fn write(output: &Option, text: &str) -> anyhow::Result<()> { } Ok(()) } + +#[derive(Parser, Debug)] +pub struct GenerateCompletions { + /// The file to which to generate the completions. If omitted, it is generated to stdout. + #[clap(short = 'o')] + pub output: Option, +} + +impl GenerateCompletions { + async fn run(&self, cmd: clap::Command<'_>) -> anyhow::Result<()> { + let writer: &mut dyn std::io::Write = match &self.output { + None => &mut std::io::stdout(), + Some(path) => &mut std::fs::File::create(path).unwrap(), + }; + + generate_completely_yaml(&cmd, writer); + + Ok(()) + } +} + +fn generate_completely_yaml(cmd: &clap::Command, buf: &mut dyn std::io::Write) { + let mut completion_map = serde_json::value::Map::new(); + + let subcommands = visible_subcommands(cmd); + + insert_array( + &mut completion_map, + cmd.get_name(), + subcommands.iter().map(|sc| sc.get_name()), + ); + + for subcmd in subcommands { + append_subcommand(&mut completion_map, subcmd, &format!("{} ", cmd.get_name())); + } + + let j = serde_json::Value::Object(completion_map); + serde_json::to_writer_pretty(buf, &j).unwrap(); +} + +fn append_subcommand( + completion_map: &mut serde_json::value::Map, + subcmd: &clap::Command<'_>, + prefix: &str, +) { + let key = format!("{}{}", prefix, subcmd.get_name()); + + let subsubcmds = visible_subcommands(subcmd); + + let positionals = subcmd + .get_arguments() + .filter(|a| a.is_positional()) + .map(|a| hint(&key, a).to_owned()) + .filter(|h| !h.is_empty()); + let subsubcmd_names = subsubcmds.iter().map(|c| c.get_name().to_owned()); + let flags = subcmd + .get_arguments() + .filter(|a| !a.is_hide_set()) + .flat_map(long_and_short); + let subcmd_options = positionals.chain(subsubcmd_names).chain(flags); + + insert_array(completion_map, &key, subcmd_options); + + for arg in subcmd.get_arguments() { + // We have already done positionals - this is for `cmd*--flag` arrays + if arg.is_positional() || !arg.is_takes_value_set() { + continue; + } + + let hint = hint(&key, arg); + for flag in long_and_short(arg) { + let key = format!("{key}*{flag}"); + insert_array(completion_map, &key, std::iter::once(hint)); + } + } + + for subsubcmd in &subsubcmds { + append_subcommand(completion_map, subsubcmd, &format!("{key} ")); + } +} + +fn hint(full_cmd: &str, arg: &clap::Arg<'_>) -> &'static str { + match arg.get_value_hint() { + clap::ValueHint::AnyPath => "", + clap::ValueHint::FilePath => "", + clap::ValueHint::DirPath => "", + _ => custom_hint(full_cmd, arg), + } +} + +fn custom_hint(full_cmd: &str, arg: &clap::Arg<'_>) -> &'static str { + let arg_name = arg.get_long(); + + match (full_cmd, arg_name) { + // ("spin build", Some("component-id")) - no existing cmd. We'd ideally want a way to infer app path too + ("spin new", Some("template")) => "$(spin templates list --format names-only 2>/dev/null)", + ("spin plugins uninstall", None) => { + "$(spin plugins list --installed --format names-only 2>/dev/null)" + } + ("spin plugins upgrade", None) => { + "$(spin plugins list --installed --format names-only 2>/dev/null)" + } + ("spin templates uninstall", None) => { + "$(spin templates list --format names-only 2>/dev/null)" + } + // ("spin up", Some("component-id")) - no existing cmd. We'd ideally want a way to infer app path too + _ => "", + } +} + +fn visible_subcommands<'a, 'b>(cmd: &'a clap::Command<'b>) -> Vec<&'a clap::Command<'b>> { + cmd.get_subcommands() + .filter(|sc| !sc.is_hide_set()) + .collect() +} + +fn insert_array>( + map: &mut serde_json::value::Map, + key: impl Into, + values: impl Iterator, +) { + let key = key.into(); + let values = values + .map(|s| serde_json::Value::String(s.into())) + .collect(); + map.insert(key, values); +} + +fn long_and_short(arg: &clap::Arg<'_>) -> Vec { + let mut result = vec![]; + if let Some(c) = arg.get_short() { + result.push(format!("-{c}")); + } + if let Some(s) = arg.get_long() { + result.push(format!("--{s}")); + } + result +} diff --git a/src/commands/new.rs b/src/commands/new.rs index a99e939ff..2a0ec69bf 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -42,7 +42,7 @@ pub struct TemplateNewCommandCore { /// The directory in which to create the new application or component. /// The default is the name argument. - #[clap(short = 'o', long = "output", group = "location")] + #[clap(short = 'o', long = "output", group = "location", value_hint = clap::ValueHint::DirPath)] pub output_path: Option, /// Create the new application or component in the current directory. @@ -56,7 +56,7 @@ pub struct TemplateNewCommandCore { /// A TOML file which contains parameter values in name = "value" format. /// Parameters passed as CLI option overwrite parameters specified in the /// file. - #[clap(long = "values-file")] + #[clap(long = "values-file", value_hint = clap::ValueHint::FilePath)] pub values_file: Option, /// An optional argument that allows to skip prompts for the manifest file @@ -96,6 +96,7 @@ pub struct AddCommand { name = APP_MANIFEST_FILE_OPT, short = 'f', long = "file", + value_hint = clap::ValueHint::AnyPath, )] pub app: Option, } diff --git a/src/commands/plugins.rs b/src/commands/plugins.rs index d960862d4..9fadc3479 100644 --- a/src/commands/plugins.rs +++ b/src/commands/plugins.rs @@ -78,6 +78,7 @@ pub struct Install { long = "file", conflicts_with = PLUGIN_REMOTE_PLUGIN_MANIFEST_OPT, conflicts_with = PLUGIN_NAME_OPT, + value_hint = clap::ValueHint::FilePath, )] pub local_manifest_src: Option, @@ -199,6 +200,7 @@ pub struct Upgrade { short = 'f', long = "file", conflicts_with = PLUGIN_REMOTE_PLUGIN_MANIFEST_OPT, + value_hint = clap::ValueHint::AnyPath, )] pub local_manifest_src: Option, @@ -627,6 +629,7 @@ pub struct List { pub enum ListFormat { Plain, Json, + NamesOnly, } impl List { @@ -650,6 +653,7 @@ impl List { match self.format { ListFormat::Plain => Self::print_plain(&plugins), ListFormat::Json => Self::print_json(&plugins), + ListFormat::NamesOnly => Self::print_names_only(&plugins), } } @@ -686,6 +690,16 @@ impl List { println!("{json_text}"); Ok(()) } + + fn print_names_only(plugins: &[PluginDescriptor]) -> anyhow::Result<()> { + let names: std::collections::HashSet<_> = plugins.iter().map(|p| &p.name).collect(); + + for name in names { + println!("{name}"); + } + + Ok(()) + } } /// Search for plugins by name. diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 39a9cc480..8c6ef730c 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -37,6 +37,7 @@ pub struct Push { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::AnyPath, )] pub app_source: Option, @@ -68,7 +69,7 @@ pub struct Push { pub reference: String, /// Cache directory for downloaded registry data. - #[clap(long)] + #[clap(long, value_hint = clap::ValueHint::DirPath)] pub cache_dir: Option, /// Specifies the OCI image manifest annotations (in key=value format). @@ -138,7 +139,7 @@ pub struct Pull { pub reference: String, /// Cache directory for downloaded registry data. - #[clap(long)] + #[clap(long, value_hint = clap::ValueHint::DirPath)] pub cache_dir: Option, } diff --git a/src/commands/templates.rs b/src/commands/templates.rs index eb35bf923..2c2c98aa8 100644 --- a/src/commands/templates.rs +++ b/src/commands/templates.rs @@ -79,6 +79,7 @@ pub struct Install { long = "dir", conflicts_with = INSTALL_FROM_GIT_OPT, conflicts_with = INSTALL_FROM_TAR_OPT, + value_hint = clap::ValueHint::DirPath, )] pub dir: Option, @@ -490,6 +491,7 @@ pub struct List { #[derive(ValueEnum, Clone, Debug)] pub enum ListFormat { Table, + NamesOnly, Json, } @@ -508,6 +510,7 @@ impl List { prompt_install_default_templates(&template_manager).await?; } ListFormat::Table => self.print_templates_table(&list_results), + ListFormat::NamesOnly => self.print_templates_plain(&list_results), ListFormat::Json => self.print_templates_json(&list_results)?, }; @@ -565,6 +568,12 @@ impl List { } } + fn print_templates_plain(&self, list_results: &ListResults) { + for template in &list_results.templates { + println!("{}", template.id()); + } + } + fn print_templates_json(&self, list_results: &ListResults) -> anyhow::Result<()> { let json_vals: Vec<_> = list_results .templates diff --git a/src/commands/up.rs b/src/commands/up.rs index 9e11fb58c..ffc5d21a5 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -55,6 +55,7 @@ pub struct UpCommand { short = 'f', long = "from", group = "source", + value_hint = clap::ValueHint::AnyPath, // it accepts other things, but this is a good hint )] pub app_source: Option, @@ -66,6 +67,7 @@ pub struct UpCommand { long = "from-file", alias = "file", group = "source", + value_hint = clap::ValueHint::AnyPath, )] pub file_source: Option, @@ -93,11 +95,11 @@ pub struct UpCommand { pub env: Vec<(String, String)>, /// Temporary directory for the static assets of the components. - #[clap(long = "temp", alias = "tmp")] + #[clap(long = "temp", alias = "tmp", value_hint = clap::ValueHint::DirPath)] pub tmp: Option, /// Cache directory for downloaded components and assets. - #[clap(long)] + #[clap(long, value_hint = clap::ValueHint::DirPath)] pub cache_dir: Option, /// For local apps with directory mounts and no excluded files, mount them directly instead of using a temporary diff --git a/src/commands/watch.rs b/src/commands/watch.rs index a9a4cde60..648001d6b 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -41,6 +41,7 @@ pub struct WatchCommand { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::AnyPath, )] pub app_source: Option,