use crate::cli::{CompletionArgs, clap_command};
use crate::config::{default_config_root, load_config_store};
use crate::env_vars;
use crate::error::{Error, Result, error_fn};
use clap::builder::PossibleValuesParser;
use clap_complete::{Shell, generate};
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::io::Cursor;
use std::path::Path;
pub(super) fn run(args: CompletionArgs) -> Result<String> {
let shell = match args.shell {
Some(shell) => shell,
None => detect_current_shell()?,
};
let mut command = completion_command()?;
let mut output = Cursor::new(Vec::new());
generate(shell, &mut command, "lsp-cli", &mut output);
String::from_utf8(output.into_inner())
.map(|output| normalize_completion_output(shell, output, "lsp-cli"))
.map_err(error_fn!(
Error::unexpected,
"completion output was not valid UTF-8"
))
}
fn normalize_completion_output(shell: Shell, output: String, bin_name: &str) -> String {
if shell != Shell::Bash || !bin_name.contains('-') {
return output;
}
output.replace(
&bin_name.replace('-', "__subcmd__"),
&bin_name.replace('-', "__"),
)
}
fn completion_command() -> Result<clap::Command> {
let values = load_completion_values()?;
Ok(attach_completion_values(clap_command(), &values))
}
fn load_completion_values() -> Result<CompletionValues> {
let config_root = default_config_root()
.map_err(|error| error.with_prefix("failed to resolve config root for completion"))?;
let config = load_config_store(&config_root).map_err(|error| {
error.with_prefix(format!(
"failed to load completion values from {}",
config_root.display()
))
})?;
Ok(CompletionValues {
languages: config
.filetypes
.into_iter()
.map(|filetype| filetype.id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
lsps: config
.lsps
.into_iter()
.map(|lsp| lsp.name)
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
})
}
fn attach_completion_values(command: clap::Command, values: &CompletionValues) -> clap::Command {
command
.mut_args(|arg| match arg.get_long() {
Some("lang") => arg.value_parser(PossibleValuesParser::new(values.languages.clone())),
Some("lsp") => arg.value_parser(PossibleValuesParser::new(values.lsps.clone())),
_ => arg,
})
.mut_subcommands(|subcommand| attach_completion_values(subcommand, values))
}
struct CompletionValues {
languages: Vec<String>,
lsps: Vec<String>,
}
pub(super) fn detect_current_shell() -> Result<clap_complete::Shell> {
clap_complete::Shell::from_env()
.ok_or(())
.or_else(|()| detect_shell_from_env(env_vars::shell().as_deref()))
}
pub(super) fn detect_shell_from_env(shell: Option<&OsStr>) -> Result<clap_complete::Shell> {
let Some(shell) = shell else {
return Err(Error::invalid_input(
"could not detect current shell from $SHELL; pass one explicitly like `lsp-cli completion bash`",
));
};
clap_complete::Shell::from_shell_path(shell).ok_or_else(|| {
Error::invalid_input(format!(
"could not map current shell from $SHELL={}; pass one explicitly like `lsp-cli completion bash`",
Path::new(shell).display()
))
})
}
#[cfg(test)]
mod tests {
use super::{detect_shell_from_env, normalize_completion_output, run};
use crate::cli::CompletionArgs;
use crate::error::Error;
use crate::test_support::{TestDir, env_var, with_env_vars};
use clap_complete::Shell;
use std::process::Command;
#[test]
fn generates_bash_completion_script() {
let output = run(CompletionArgs {
shell: Some(Shell::Bash),
})
.expect("completion script should generate");
assert!(output.contains("lsp-cli"));
assert!(output.contains("detect"));
assert!(output.contains("grep"));
assert!(output.contains("references"));
assert!(output.contains("callers"));
assert!(output.contains("callees"));
assert!(output.contains("definition"));
assert!(output.contains("declaration"));
assert!(output.contains("implementation"));
assert!(output.contains("type-definition"));
assert!(output.contains("list-files"));
assert!(output.contains("list-functions"));
assert!(output.contains("list-symbols"));
assert!(output.contains("completion"));
assert!(output.contains("agent-skill"));
}
#[test]
fn normalizes_bash_labels_for_hyphenated_binary_names() {
assert_eq!(
normalize_completion_output(
Shell::Bash,
"lsp__subcmd__cli__subcmd__detect".to_string(),
"lsp-cli",
),
"lsp__cli__subcmd__detect"
);
}
#[test]
fn bash_detect_lsp_completion_reaches_value_candidates() {
let script = run(CompletionArgs {
shell: Some(Shell::Bash),
})
.expect("bash completion should generate");
let output = Command::new("/bin/bash")
.arg("-lc")
.arg(
"source /dev/stdin && COMP_WORDS=(lsp-cli detect playground/c --lsp \"\") && COMP_CWORD=4 && COMPREPLY=() && _lsp__cli lsp-cli \"\" --lsp && printf '%s\n' \"${COMPREPLY[@]}\"",
)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin should be piped")
.write_all(script.as_bytes())?;
child.wait_with_output()
})
.expect("bash subprocess should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf-8");
assert!(stdout.contains("clangd"));
}
#[test]
fn includes_configured_languages_and_servers_in_completion_script() {
let config = TestDir::new("completion-config");
config.write_file(
"filetypes/python.yaml",
"extensions:\n - py\npatterns: []\n",
);
config.write_file("filetypes/rust.yaml", "extensions:\n - rs\npatterns: []\n");
config.write_file(
"lsp/pyright.yaml",
"filetypes:\n - python\nroot_markers: []\nname: pyright\ncmdline: pyright-langserver --stdio\n",
);
config.write_file(
"lsp/rust_analyzer.yaml",
"filetypes:\n - rust\nroot_markers: []\nname: rust-analyzer\ncmdline: rust-analyzer\n",
);
let output = with_env_vars(&[env_var("LSP_DATA", config.path())], || {
run(CompletionArgs {
shell: Some(Shell::Bash),
})
})
.expect("completion script should include configured values");
assert!(output.contains("python"));
assert!(output.contains("rust"));
assert!(output.contains("pyright"));
assert!(output.contains("rust-analyzer"));
}
#[test]
fn errors_when_completion_values_cannot_be_loaded() {
let config = TestDir::new("completion-missing-config");
let error = with_env_vars(&[env_var("LSP_DATA", config.path())], || {
run(CompletionArgs {
shell: Some(Shell::Bash),
})
})
.expect_err("completion should fail when config cannot be loaded");
assert!(error.contains("failed to load completion values from"));
assert!(error.contains("missing directory"));
}
#[test]
fn detects_shell_from_shell_path() {
assert_eq!(
detect_shell_from_env(Some("/bin/zsh".as_ref())),
Ok(Shell::Zsh)
);
assert_eq!(
detect_shell_from_env(Some("/usr/bin/powershell".as_ref())),
Ok(Shell::PowerShell)
);
}
#[test]
fn errors_when_shell_env_is_missing() {
assert!(matches!(
detect_shell_from_env(None),
Err(Error::InvalidInput(message))
if message == "could not detect current shell from $SHELL; pass one explicitly like `lsp-cli completion bash`"
));
}
#[test]
fn errors_when_shell_env_is_unsupported() {
assert!(matches!(
detect_shell_from_env(Some("/bin/sh".as_ref())),
Err(Error::InvalidInput(message))
if message == "could not map current shell from $SHELL=/bin/sh; pass one explicitly like `lsp-cli completion bash`"
));
}
}