aboutsummaryrefslogtreecommitdiff
path: root/src/cli/completion.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli/completion.rs')
-rw-r--r--src/cli/completion.rs113
1 files changed, 113 insertions, 0 deletions
diff --git a/src/cli/completion.rs b/src/cli/completion.rs
new file mode 100644
index 00000000..f049175d
--- /dev/null
+++ b/src/cli/completion.rs
@@ -0,0 +1,113 @@
+use std::io::Write;
+
+use clap_complete::{generate, Shell};
+
+pub(super) const SUPPORTED_SHELLS: [&str; 5] = ["bash", "elvish", "fish", "powershell", "zsh"];
+
+pub(super) fn supported_shells_usage() -> String {
+ SUPPORTED_SHELLS.join("|")
+}
+
+pub(super) fn run_completion_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(shell) = args.first().map(String::as_str) else {
+ print_completion_help();
+ return Ok(2);
+ };
+ if matches!(shell, "help" | "--help" | "-h") {
+ print_completion_help();
+ return Ok(0);
+ }
+ if args.len() != 1 {
+ print_completion_help();
+ return Ok(2);
+ }
+
+ let Some(shell) = parse_shell(shell) else {
+ eprintln!("unknown shell: {shell}");
+ print_completion_help();
+ return Ok(2);
+ };
+
+ let mut command = super::spec::command();
+ if matches!(shell, Shell::Zsh) {
+ let mut output = Vec::new();
+ generate(shell, &mut command, "herdr", &mut output);
+ let script = String::from_utf8(output).map_err(|err| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, err.utf8_error())
+ })?;
+ std::io::stdout().write_all(space_separated_zsh_long_options(&script).as_bytes())?;
+ } else {
+ generate(shell, &mut command, "herdr", &mut std::io::stdout());
+ }
+ Ok(0)
+}
+
+fn space_separated_zsh_long_options(script: &str) -> String {
+ let mut output = String::with_capacity(script.len());
+ let mut rest = script;
+
+ while let Some(start) = rest.find("--") {
+ output.push_str(&rest[..start]);
+ let candidate = &rest[start + 2..];
+ let name_len = candidate
+ .chars()
+ .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
+ .map(char::len_utf8)
+ .sum::<usize>();
+ if name_len > 0 && candidate[name_len..].starts_with("=[") {
+ output.push_str("--");
+ output.push_str(&candidate[..name_len]);
+ output.push('[');
+ rest = &candidate[name_len + 2..];
+ } else {
+ output.push_str("--");
+ rest = candidate;
+ }
+ }
+
+ output.push_str(rest);
+ output
+}
+
+fn parse_shell(shell: &str) -> Option<Shell> {
+ match shell {
+ value if value == SUPPORTED_SHELLS[0] => Some(Shell::Bash),
+ value if value == SUPPORTED_SHELLS[1] => Some(Shell::Elvish),
+ value if value == SUPPORTED_SHELLS[2] => Some(Shell::Fish),
+ value if value == SUPPORTED_SHELLS[3] => Some(Shell::PowerShell),
+ value if value == SUPPORTED_SHELLS[4] => Some(Shell::Zsh),
+ _ => None,
+ }
+}
+
+fn print_completion_help() {
+ eprintln!("usage: herdr completion <{}>", supported_shells_usage());
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn parses_supported_shells() {
+ assert!(matches!(
+ super::parse_shell("zsh"),
+ Some(clap_complete::Shell::Zsh)
+ ));
+ assert!(matches!(
+ super::parse_shell("powershell"),
+ Some(clap_complete::Shell::PowerShell)
+ ));
+ assert!(super::parse_shell("tcsh").is_none());
+ }
+
+ #[test]
+ fn zsh_long_options_use_space_separated_arguments() {
+ let script =
+ "'--cwd=[]:PATH:_files' \\\n'--json[]' \\\n'--split=[]:DIRECTION:(right down)' \\";
+ let normalized = super::space_separated_zsh_long_options(script);
+ assert!(normalized.contains("'--cwd[]:PATH:_files'"));
+ assert!(normalized.contains("'--json[]'"));
+ assert!(normalized.contains("'--split[]:DIRECTION:(right down)'"));
+ assert!(!normalized.contains("--cwd=[]"));
+ assert!(!normalized.contains("--split=[]"));
+ }
+}