Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions clap_complete/src/engine/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates parts of complete_path and has major bugs in it

value_path: &std::path::Path,
current_dir: Option<&std::path::Path>,
) -> Option<std::path::PathBuf> {
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<CompletionCandidate> {
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(&current_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
}

Expand Down
196 changes: 196 additions & 0 deletions clap_complete/tests/testsuite/engine.rs
Copy link
Member

@epage epage Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test cases:

  • when a path element matches both as a literal and a prefix, in particular with a path that is a mixture of literals and prefixes
  • ambiguous prefix, particularly early in the path

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to add duplicate coverage

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the test organization is suspect

Original file line number Diff line number Diff line change
Expand Up @@ -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<str>, current_dir: Option<&Path>) -> String {
let input = args.as_ref();
let mut args = vec![std::ffi::OsString::from(cmd.get_name())];
Expand Down
Loading