diff --git a/clap_complete/src/engine/custom.rs b/clap_complete/src/engine/custom.rs index 52f8117b92c..d590d72fe85 100644 --- a/clap_complete/src/engine/custom.rs +++ b/clap_complete/src/engine/custom.rs @@ -356,6 +356,98 @@ pub(crate) fn complete_path( potential.sort(); completions.extend(potential); + // If no results were found and the path contains intermediate components, + // try abbreviated path matching where each component is treated as a prefix. + if completions.is_empty() && !value_os.is_empty() { + let value_path = std::path::Path::new(value_os); + let components: Vec<&OsStr> = value_path + .iter() + .filter(|c| *c != OsStr::new(".")) + .collect(); + if components.len() >= 2 { + let base_dir = resolve_base_dir(value_path, current_dir); + if let Some(base_dir) = base_dir { + completions = complete_path_abbreviated(&components, &base_dir, is_wanted); + completions.sort(); + } + } + } + + completions +} + +/// Resolve the base directory for path completion, handling absolute, home-relative, +/// and relative paths. +fn resolve_base_dir( + value_path: &std::path::Path, + current_dir: Option<&std::path::Path>, +) -> Option { + if value_path.is_absolute() { + Some(std::path::PathBuf::from("/")) + } else if value_path.iter().next() == Some(OsStr::new("~")) { + std::env::home_dir() + } else { + current_dir.map(|d| d.to_owned()) + } +} + +/// Recursively match each path component as a prefix to support abbreviated paths +/// like `tar/de/inc` matching `target/debug/incremental`. +fn complete_path_abbreviated( + components: &[&OsStr], + search_dir: &std::path::Path, + is_wanted: &dyn Fn(&std::path::Path) -> bool, +) -> Vec { + let mut completions = Vec::new(); + + if components.is_empty() { + return completions; + } + + let current_prefix = components[0].to_string_lossy(); + let is_last = components.len() == 1; + + let entries = match std::fs::read_dir(search_dir) { + Ok(entries) => entries, + Err(_) => return completions, + }; + + for entry in entries.filter_map(Result::ok) { + let file_name = entry.file_name(); + if !file_name.starts_with(¤t_prefix) { + continue; + } + + let entry_path = entry.path(); + + if is_last { + // Last component: produce final completion candidates + if entry_path.is_dir() { + let mut suggestion = std::path::PathBuf::from(&file_name); + suggestion.push(""); // Ensure trailing `/` + let candidate = CompletionCandidate::new(suggestion.as_os_str().to_owned()) + .hide(is_hidden(&file_name)); + if is_wanted(&entry_path) { + completions.push(candidate); + } + } else if is_wanted(&entry_path) { + let candidate = + CompletionCandidate::new(file_name.clone()).hide(is_hidden(&file_name)); + completions.push(candidate); + } + } else if entry_path.is_dir() { + // Intermediate component: recurse into matching directories + let sub_results = complete_path_abbreviated(&components[1..], &entry_path, is_wanted); + for sub in sub_results { + let full_value = std::path::Path::new(&file_name).join(sub.get_value()); + completions.push( + CompletionCandidate::new(full_value.as_os_str().to_owned()) + .hide(is_hidden(&file_name)), + ); + } + } + } + completions } diff --git a/clap_complete/tests/testsuite/engine.rs b/clap_complete/tests/testsuite/engine.rs index b8f03ee07e2..3e91f89d3b8 100644 --- a/clap_complete/tests/testsuite/engine.rs +++ b/clap_complete/tests/testsuite/engine.rs @@ -1443,6 +1443,202 @@ pos-c ); } +#[test] +fn suggest_multi_path_element_prefix() { + let mut cmd = Command::new("dynamic") + .arg( + clap::Arg::new("input") + .long("input") + .short('i') + .value_hint(clap::ValueHint::AnyPath), + ); + + let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap(); + let testdir_path = testdir.path().unwrap(); + + // Create a nested directory structure + fs::create_dir_all(testdir_path.join("target/debug/incremental")).unwrap(); + fs::create_dir_all(testdir_path.join("target/release")).unwrap(); + fs::create_dir_all(testdir_path.join("tests")).unwrap(); + fs::write(testdir_path.join("target/debug/binary"), "").unwrap(); + + // 1. Single-component abbreviation: standard prefix matching still works + assert_data_eq!( + complete!(cmd, "--input tar[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/"] + ); + + // 2. Two-component abbreviation: "tar/de" matches "target/debug/" + assert_data_eq!( + complete!(cmd, "--input tar/de[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/"] + ); + + // 3. Full abbreviated path: "tar/de/inc" matches "target/debug/incremental/" + assert_data_eq!( + complete!(cmd, "--input tar/de/inc[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/incremental/"] + ); + + // 4. Abbreviated path to a file: "tar/de/bin" matches "target/debug/binary" + assert_data_eq!( + complete!(cmd, "--input tar/de/bin[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/binary"] + ); + + // 5. Multiple matches for last component: "tar/re" matches "target/release/" + assert_data_eq!( + complete!(cmd, "--input tar/re[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/release/"] + ); + + // 6. Single char per component: "t/d/i" matches "target/debug/incremental/" + assert_data_eq!( + complete!(cmd, "--input t/d/i[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/incremental/"] + ); +} + +#[test] +fn suggest_multi_path_element_prefix_edge_cases() { + let mut cmd = Command::new("dynamic") + .arg( + clap::Arg::new("input") + .long("input") + .short('i') + .value_hint(clap::ValueHint::AnyPath), + ); + + let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap(); + let testdir_path = testdir.path().unwrap(); + + // Create directory structure + fs::create_dir_all(testdir_path.join("target/debug/incremental")).unwrap(); + fs::create_dir_all(testdir_path.join("target/release")).unwrap(); + fs::create_dir_all(testdir_path.join("tests")).unwrap(); + fs::write(testdir_path.join("target/debug/binary"), "").unwrap(); + + // 7. Non-existent abbreviated path: no match, should return empty + assert_data_eq!( + complete!(cmd, "--input foo/bar/baz[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 8. Empty input: should list top-level directory contents (normal behavior) + assert_data_eq!( + complete!(cmd, "--input [TAB]", current_dir = Some(testdir_path)), + snapbox::str![[r#" +. +target/ +tests/ +"#]] + ); + + // 9. Trailing slash on abbreviated component: "tar/" tries to list contents of + // non-existent "tar/" directory, falls back to abbreviated matching + assert_data_eq!( + complete!(cmd, "--input tar/[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 10. Single component with no slash: normal prefix completion, no abbreviation needed + assert_data_eq!( + complete!(cmd, "--input targ[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/"] + ); + + // 11. Path with "./" prefix: abbreviated matching should work through dot-prefix + assert_data_eq!( + complete!(cmd, "--input ./tar/de[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/"] + ); + + // 12. Ambiguous first component: both "target/" and "tests/" start with "t", + // but only "target/" has a "d" subdirectory + assert_data_eq!( + complete!(cmd, "--input t/d[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/"] + ); +} + +#[test] +fn suggest_multi_path_element_prefix_existing_paths() { + let mut cmd = Command::new("dynamic") + .arg( + clap::Arg::new("input") + .long("input") + .short('i') + .value_hint(clap::ValueHint::AnyPath), + ); + + let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap(); + let testdir_path = testdir.path().unwrap(); + + // Create directory structure + fs::create_dir_all(testdir_path.join("target/debug/incremental")).unwrap(); + fs::create_dir_all(testdir_path.join("target/release")).unwrap(); + fs::create_dir_all(testdir_path.join("tests")).unwrap(); + fs::write(testdir_path.join("target/debug/binary"), "").unwrap(); + + // 13. Normal full path completion: "target/" lists contents of target/ + assert_data_eq!( + complete!(cmd, "--input target/[TAB]", current_dir = Some(testdir_path)), + snapbox::str![[r#" +target/debug/ +target/release/ +"#]] + ); + + // 14. Normal file completion within a real directory path + assert_data_eq!( + complete!(cmd, "--input target/debug/bin[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/binary"] + ); + + // 15. Normal full path prefix completion still works + assert_data_eq!( + complete!(cmd, "--input target/de[TAB]", current_dir = Some(testdir_path)), + snapbox::str!["target/debug/"] + ); +} + +#[test] +fn suggest_multi_path_element_prefix_hidden_files() { + let mut cmd = Command::new("dynamic") + .arg( + clap::Arg::new("input") + .long("input") + .short('i') + .value_hint(clap::ValueHint::AnyPath), + ); + + let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap(); + let testdir_path = testdir.path().unwrap(); + + // Create directory structure with hidden directories + fs::create_dir_all(testdir_path.join(".config/nvim/lua")).unwrap(); + fs::create_dir_all(testdir_path.join(".cache/downloads")).unwrap(); + fs::write(testdir_path.join(".config/nvim/init.lua"), "").unwrap(); + + // Hidden directory abbreviated paths: ".c/n" matches ".config/nvim/" + assert_data_eq!( + complete!(cmd, "--input .c/n[TAB]", current_dir = Some(testdir_path)), + snapbox::str![".config/nvim/"] + ); + + // Hidden directory deep abbreviated paths: ".c/n/l" matches ".config/nvim/lua/" + assert_data_eq!( + complete!(cmd, "--input .c/n/l[TAB]", current_dir = Some(testdir_path)), + snapbox::str![".config/nvim/lua/"] + ); + + // Hidden directory abbreviated path to file: ".c/n/i" matches ".config/nvim/init.lua" + assert_data_eq!( + complete!(cmd, "--input .c/n/i[TAB]", current_dir = Some(testdir_path)), + snapbox::str![".config/nvim/init.lua"] + ); +} + fn complete(cmd: &mut Command, args: impl AsRef, current_dir: Option<&Path>) -> String { let input = args.as_ref(); let mut args = vec![std::ffi::OsString::from(cmd.get_name())];