From 6f8972884f4dd6af9e80a44fb6c13cd25668c649 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Fri, 13 Feb 2026 00:58:23 +0000 Subject: [PATCH 1/2] test(complete): add tests for multi-path-element prefix matching Add comprehensive tests covering abbreviated path completion including good inputs, edge cases, existing path behavior, and hidden files. Abbreviated paths currently return empty since the feature is not yet implemented. --- clap_complete/tests/testsuite/engine.rs | 195 ++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/clap_complete/tests/testsuite/engine.rs b/clap_complete/tests/testsuite/engine.rs index b8f03ee07e2..662d4da3c53 100644 --- a/clap_complete/tests/testsuite/engine.rs +++ b/clap_complete/tests/testsuite/engine.rs @@ -1443,6 +1443,201 @@ 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" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input tar/de[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 3. Full abbreviated path: "tar/de/inc" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input tar/de/inc[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 4. Abbreviated path to a file: "tar/de/bin" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input tar/de/bin[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 5. Multiple matches for last component: "tar/re" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input tar/re[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 6. Single char per component: "t/d/i" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input t/d/i[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); +} + +#[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: doesn't work without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input ./tar/de[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // 12. Ambiguous first component: doesn't work without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input t/d[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); +} + +#[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" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input .c/n[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // Hidden directory deep abbreviated paths: ".c/n/l" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input .c/n/l[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); + + // Hidden directory abbreviated path to file: ".c/n/i" doesn't match without abbreviated path support + assert_data_eq!( + complete!(cmd, "--input .c/n/i[TAB]", current_dir = Some(testdir_path)), + snapbox::str![""] + ); +} + 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())]; From afeb916e1856358fb4d94863640c1a664faa437c Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Fri, 13 Feb 2026 00:58:51 +0000 Subject: [PATCH 2/2] feat(complete): support multi-path-element prefix completion When no direct match is found, treat each /-separated component as a prefix and recursively match directories. For example, tar/de/inc now completes to target/debug/incremental/. --- clap_complete/src/engine/custom.rs | 92 +++++++++++++++++++++++++ clap_complete/tests/testsuite/engine.rs | 41 +++++------ 2 files changed, 113 insertions(+), 20 deletions(-) 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 662d4da3c53..3e91f89d3b8 100644 --- a/clap_complete/tests/testsuite/engine.rs +++ b/clap_complete/tests/testsuite/engine.rs @@ -1468,34 +1468,34 @@ fn suggest_multi_path_element_prefix() { snapbox::str!["target/"] ); - // 2. Two-component abbreviation: "tar/de" doesn't match without abbreviated path support + // 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![""] + snapbox::str!["target/debug/"] ); - // 3. Full abbreviated path: "tar/de/inc" doesn't match without abbreviated path support + // 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![""] + snapbox::str!["target/debug/incremental/"] ); - // 4. Abbreviated path to a file: "tar/de/bin" doesn't match without abbreviated path support + // 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![""] + snapbox::str!["target/debug/binary"] ); - // 5. Multiple matches for last component: "tar/re" doesn't match without abbreviated path support + // 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![""] + snapbox::str!["target/release/"] ); - // 6. Single char per component: "t/d/i" doesn't match without abbreviated path support + // 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![""] + snapbox::str!["target/debug/incremental/"] ); } @@ -1547,16 +1547,17 @@ tests/ snapbox::str!["target/"] ); - // 11. Path with "./" prefix: doesn't work without abbreviated path support + // 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![""] + snapbox::str!["target/debug/"] ); - // 12. Ambiguous first component: doesn't work without abbreviated path support + // 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![""] + snapbox::str!["target/debug/"] ); } @@ -1619,22 +1620,22 @@ fn suggest_multi_path_element_prefix_hidden_files() { 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" doesn't match without abbreviated path support + // 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![""] + snapbox::str![".config/nvim/"] ); - // Hidden directory deep abbreviated paths: ".c/n/l" doesn't match without abbreviated path support + // 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![""] + snapbox::str![".config/nvim/lua/"] ); - // Hidden directory abbreviated path to file: ".c/n/i" doesn't match without abbreviated path support + // 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![""] + snapbox::str![".config/nvim/init.lua"] ); }