Skip to content

Commit a9fedf3

Browse files
authored
Implement tab completion for file starting with "." (#271)
* Implement tab completion for . files * Add tests for tab completion
1 parent c1a4db1 commit a9fedf3

File tree

3 files changed

+121
-4
lines changed

3 files changed

+121
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/shell/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_Syste
4343
ctrlc = "3.4.5"
4444
libc = "0.2.170"
4545

46+
[dev-dependencies]
47+
tempfile = "3.14.0"
48+
4649
[package.metadata.release]
4750
# Dont publish the binary
4851
release = false

crates/shell/src/completion.rs

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,16 @@ struct FileMatch {
8585
}
8686

8787
impl FileMatch {
88-
fn from_entry(entry: fs::DirEntry, base_path: &Path) -> Option<Self> {
88+
fn from_entry(entry: fs::DirEntry, base_path: &Path, show_hidden: bool) -> Option<Self> {
8989
let metadata = match entry.metadata() {
9090
Ok(m) => m,
9191
Err(_) => return None,
9292
};
9393

9494
let name = entry.file_name().into_string().ok()?;
9595

96-
// Skip hidden files
97-
if name.starts_with('.') {
96+
// Skip hidden files unless explicitly requested
97+
if !show_hidden && name.starts_with('.') {
9898
return None;
9999
}
100100

@@ -175,12 +175,13 @@ fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec<Pair>) {
175175

176176
let search_dir = resolve_dir_path(dir_path);
177177
let only_executable = (word.starts_with("./") || word.starts_with('/')) && is_start;
178+
let show_hidden = partial_name.starts_with('.');
178179

179180
let files: Vec<FileMatch> = fs::read_dir(&search_dir)
180181
.into_iter()
181182
.flatten()
182183
.flatten()
183-
.filter_map(|entry| FileMatch::from_entry(entry, &search_dir))
184+
.filter_map(|entry| FileMatch::from_entry(entry, &search_dir, show_hidden))
184185
.filter(|f| f.name.starts_with(partial_name))
185186
.filter(|f| !only_executable || f.is_executable || f.is_dir)
186187
.collect();
@@ -250,3 +251,115 @@ impl Highlighter for ShellCompleter {
250251
impl Validator for ShellCompleter {}
251252

252253
impl Helper for ShellCompleter {}
254+
255+
#[cfg(test)]
256+
mod tests {
257+
use super::*;
258+
use rustyline::history::DefaultHistory;
259+
use std::fs;
260+
use tempfile::TempDir;
261+
262+
#[tokio::test]
263+
async fn test_complete_hidden_files_when_starting_with_dot() {
264+
let temp_dir = TempDir::new().unwrap();
265+
let temp_path = temp_dir.path();
266+
267+
// Create some test files and directories
268+
fs::File::create(temp_path.join(".gitignore")).unwrap();
269+
fs::create_dir(temp_path.join(".github")).unwrap();
270+
fs::File::create(temp_path.join(".hidden_file")).unwrap();
271+
fs::File::create(temp_path.join("visible_file.txt")).unwrap();
272+
273+
// Test completion with "." prefix
274+
let completer = ShellCompleter::new(HashSet::new());
275+
let history = DefaultHistory::new();
276+
let line = format!("cat {}/.gi", temp_path.display());
277+
let pos = line.len();
278+
let (_start, matches) = completer
279+
.complete(&line, pos, &Context::new(&history))
280+
.unwrap();
281+
282+
// Should find .gitignore and .github/
283+
assert_eq!(matches.len(), 2);
284+
let displays: Vec<&str> = matches.iter().map(|m| m.display.as_str()).collect();
285+
assert!(displays.contains(&".github/"));
286+
assert!(displays.contains(&".gitignore"));
287+
}
288+
289+
#[tokio::test]
290+
async fn test_skip_hidden_files_when_not_starting_with_dot() {
291+
let temp_dir = TempDir::new().unwrap();
292+
let temp_path = temp_dir.path();
293+
294+
// Create some test files and directories
295+
fs::File::create(temp_path.join(".gitignore")).unwrap();
296+
fs::create_dir(temp_path.join(".github")).unwrap();
297+
fs::File::create(temp_path.join("visible_file.txt")).unwrap();
298+
fs::File::create(temp_path.join("another_file.txt")).unwrap();
299+
300+
// Test completion without "." prefix
301+
let completer = ShellCompleter::new(HashSet::new());
302+
let history = DefaultHistory::new();
303+
let line = format!("cat {}/", temp_path.display());
304+
let pos = line.len();
305+
let (_start, matches) = completer
306+
.complete(&line, pos, &Context::new(&history))
307+
.unwrap();
308+
309+
// Should only find visible files, not hidden ones
310+
let displays: Vec<&str> = matches.iter().map(|m| m.display.as_str()).collect();
311+
assert!(!displays.iter().any(|d| d.starts_with('.')));
312+
assert!(displays.len() >= 2); // Should have at least the two visible files
313+
}
314+
315+
#[tokio::test]
316+
async fn test_complete_github_directory() {
317+
let temp_dir = TempDir::new().unwrap();
318+
let temp_path = temp_dir.path();
319+
320+
// Create .github directory and other dot files
321+
fs::create_dir(temp_path.join(".github")).unwrap();
322+
fs::File::create(temp_path.join(".gitignore")).unwrap();
323+
fs::File::create(temp_path.join(".git_keep")).unwrap();
324+
325+
// Test completion with ".gith" prefix
326+
let completer = ShellCompleter::new(HashSet::new());
327+
let history = DefaultHistory::new();
328+
let line = format!("cd {}/.gith", temp_path.display());
329+
let pos = line.len();
330+
let (_start, matches) = completer
331+
.complete(&line, pos, &Context::new(&history))
332+
.unwrap();
333+
334+
// Should find .github/
335+
assert_eq!(matches.len(), 1);
336+
assert_eq!(matches[0].display, ".github/");
337+
}
338+
339+
#[tokio::test]
340+
async fn test_complete_all_hidden_with_dot() {
341+
let temp_dir = TempDir::new().unwrap();
342+
let temp_path = temp_dir.path();
343+
344+
// Create several hidden files
345+
fs::File::create(temp_path.join(".env")).unwrap();
346+
fs::File::create(temp_path.join(".bashrc")).unwrap();
347+
fs::create_dir(temp_path.join(".config")).unwrap();
348+
349+
// Test completion with just "." prefix
350+
let completer = ShellCompleter::new(HashSet::new());
351+
let history = DefaultHistory::new();
352+
let line = format!("ls {}/.", temp_path.display());
353+
let pos = line.len();
354+
let (_start, matches) = completer
355+
.complete(&line, pos, &Context::new(&history))
356+
.unwrap();
357+
358+
// Should find all hidden files
359+
assert!(matches.len() >= 3);
360+
let displays: Vec<&str> = matches.iter().map(|m| m.display.as_str()).collect();
361+
assert!(displays.contains(&".env"));
362+
assert!(displays.contains(&".bashrc"));
363+
assert!(displays.contains(&".config/"));
364+
}
365+
}

0 commit comments

Comments
 (0)