diff --git a/README.md b/README.md index f11dbffdb..cc44d7bec 100644 --- a/README.md +++ b/README.md @@ -329,9 +329,9 @@ Options: -p, --full-path Search full abs. path (default: filename only) -d, --max-depth Set maximum search depth (default: none) -E, --exclude Exclude entries that match the given glob pattern - -t, --type Filter by type: file (f), directory (d/dir), symlink (l), - executable (x), empty (e), socket (s), pipe (p), char-device - (c), block-device (b) + -t, --type Filter by type: file (f), directory (d/dir), leaf (leafdir), + symlink (l), executable (x), empty (e), socket (s), pipe (p), + char-device (c), block-device (b) -e, --extension Filter by file extension -S, --size Limit results based on the size of files --changed-within Filter by file modification time (newer than) diff --git a/contrib/completion/_fd b/contrib/completion/_fd index 8055467dd..b96367073 100644 --- a/contrib/completion/_fd +++ b/contrib/completion/_fd @@ -23,6 +23,7 @@ _fd() { fd_types=( {f,file}'\:"regular files"' {d,directory}'\:"directories"' + {leaf,leafdir}'\:"directories without subdirectories"' {l,symlink}'\:"symbolic links"' {e,empty}'\:"empty files or directories"' {x,executable}'\:"executable (files)"' diff --git a/doc/fd.1 b/doc/fd.1 index df42b1724..581547ff1 100644 --- a/doc/fd.1 +++ b/doc/fd.1 @@ -207,6 +207,8 @@ Filter search by type: regular files .IP "d, dir, directory" directories +.IP "leaf, leafdir" +directories without subdirectories .IP "l, symlink" symbolic links .IP "b, block-device" @@ -247,6 +249,8 @@ Examples: - Find empty directories: fd --type empty --type directory fd -te -td + - Find leaf directories (directories without subdirectories): + fd --type leaf .RE .TP .BI "\-e, \-\-extension " ext diff --git a/src/cli.rs b/src/cli.rs index d5174689d..8754aaa49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -315,6 +315,7 @@ pub struct Opts { /// Filter the search by type: /// {n} 'f' or 'file': regular files /// {n} 'd' or 'dir' or 'directory': directories + /// {n} 'leaf' or 'leafdir': directories without subdirectories /// {n} 'l' or 'symlink': symbolic links /// {n} 's' or 'socket': socket /// {n} 'p' or 'pipe': named pipe (FIFO) @@ -353,7 +354,7 @@ pub struct Opts { hide_possible_values = true, value_enum, help = "Filter by type: file (f), directory (d/dir), symlink (l), \ - executable (x), empty (e), socket (s), pipe (p), \ + leaf (leafdir), executable (x), empty (e), socket (s), pipe (p), \ char-device (c), block-device (b)", long_help )] @@ -778,6 +779,9 @@ pub enum FileType { File, #[value(alias = "d", alias = "dir")] Directory, + /// A directory that has no subdirectories + #[value(alias = "leafdir", alias = "leaf-dir", alias = "leaf")] + Leaf, #[value(alias = "l")] Symlink, #[value(alias = "b")] diff --git a/src/filesystem.rs b/src/filesystem.rs index 4a04f9d52..f2afc99d1 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -4,7 +4,7 @@ use std::ffi::OsStr; use std::fs; use std::io; #[cfg(any(unix, target_os = "redox"))] -use std::os::unix::fs::FileTypeExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; use normpath::PathExt; @@ -59,6 +59,41 @@ pub fn is_empty(entry: &dir_entry::DirEntry) -> bool { } } +pub fn is_leaf_directory(entry: &dir_entry::DirEntry) -> bool { + if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + return false; + } + + #[cfg(any(unix, target_os = "redox"))] + if let Some(metadata) = entry.metadata() { + let link_count = metadata.nlink(); + + if link_count == 2 { + return true; + } + + if link_count > 2 { + return false; + } + } + + if let Ok(mut entries) = fs::read_dir(entry.path()) { + for child in entries.by_ref() { + match child { + Ok(dirent) => { + if dirent.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + return false; + } + } + Err(_) => return false, + } + } + true + } else { + false + } +} + #[cfg(any(unix, target_os = "redox"))] pub fn is_block_device(ft: fs::FileType) -> bool { ft.is_block_device() diff --git a/src/filetypes.rs b/src/filetypes.rs index a10924b06..e4fdf18bf 100644 --- a/src/filetypes.rs +++ b/src/filetypes.rs @@ -8,6 +8,7 @@ use faccess::PathExt; pub struct FileTypes { pub files: bool, pub directories: bool, + pub leaf_directories: bool, pub symlinks: bool, pub block_devices: bool, pub char_devices: bool, @@ -20,8 +21,11 @@ pub struct FileTypes { impl FileTypes { pub fn should_ignore(&self, entry: &dir_entry::DirEntry) -> bool { if let Some(ref entry_type) = entry.file_type() { + let is_dir = entry_type.is_dir(); + (!self.files && entry_type.is_file()) - || (!self.directories && entry_type.is_dir()) + || (!self.directories && is_dir) + || (self.leaf_directories && is_dir && !filesystem::is_leaf_directory(entry)) || (!self.symlinks && entry_type.is_symlink()) || (!self.block_devices && filesystem::is_block_device(*entry_type)) || (!self.char_devices && filesystem::is_char_device(*entry_type)) diff --git a/src/main.rs b/src/main.rs index fafb3b900..5b7872c23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,6 +275,10 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result file_types.files = true, Directory => file_types.directories = true, + Leaf => { + file_types.leaf_directories = true; + file_types.directories = true; + } Symlink => file_types.symlinks = true, Executable => { file_types.executables_only = true; diff --git a/tests/tests.rs b/tests/tests.rs index e3d43b74b..f4db162ea 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1293,6 +1293,33 @@ fn test_type() { te.assert_output(&["--type", "l"], "symlink"); } +#[test] +fn test_type_leaf() { + let te = TestEnv::new( + &[ + "parent/child/grandchild", + "parent/child/sibling", + "parent/solo", + ], + &["parent/child/file.txt"], + ); + + te.assert_output( + &["--type", "leaf"], + "parent/child/grandchild/ + parent/child/sibling/ + parent/solo/", + ); + + te.assert_output( + &["--type", "leafdir", "--type", "file"], + "parent/child/file.txt + parent/child/grandchild/ + parent/child/sibling/ + parent/solo/", + ); +} + /// Test `--type executable` #[cfg(unix)] #[test]