diff --git a/doc/lsd.md b/doc/lsd.md index 78a120465..089942b31 100644 --- a/doc/lsd.md +++ b/doc/lsd.md @@ -122,6 +122,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich `-I, --ignore-glob ...` : Do not display files/directories with names matching the glob pattern(s). More than one can be specified by repeating the argument [default: ] +`--max-shown ` +: In tree layout, cap the number of entries shown at each depth level. Remaining entries are rolled up into a `... and N more` summary line + `--permission ...` : How to display permissions [default: rwx for linux, attributes for windows] [possible values: rwx, octal, attributes, disable] @@ -149,6 +152,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich `--truncate-owner-marker` : Truncation marker appended to a truncated user or group name +`--tree-filter ...` +: In tree layout, only display files matching the glob pattern(s). Directories are always shown. More than one can be specified by repeating the argument + # ARGS `...` diff --git a/doc/samples/config-sample.yaml b/doc/samples/config-sample.yaml index 3da640e5d..9d2a4ba40 100644 --- a/doc/samples/config-sample.yaml +++ b/doc/samples/config-sample.yaml @@ -147,3 +147,14 @@ truncate-owner: after: # String to be appended to a name if truncated. marker: "" + +# == Max Shown == +# In tree layout, cap the number of entries shown at each depth level. Entries +# past the cap roll up into a "... and N more" summary line. +# max-shown: 10 + +# == Tree Filter == +# In tree layout, only display files matching these globs. Directories are +# always shown. Multiple globs may be listed. +# tree-filter: +# - "*.rs" diff --git a/src/app.rs b/src/app.rs index c8d80c7cb..0d1c2bea6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -197,6 +197,14 @@ pub struct Cli { #[arg(short = 'N', long)] pub literal: bool, + /// only show entries matching glob in tree layout (repeatable, dirs always shown) + #[arg(long, value_name = "GLOB")] + pub tree_filter: Vec, + + /// max items to show per directory level in tree layout + #[arg(long, value_name = "NUM")] + pub max_shown: Option, + /// Print help information #[arg(long, action = ArgAction::Help)] help: (), diff --git a/src/config_file.rs b/src/config_file.rs index 3a5dbe53b..783e79346 100644 --- a/src/config_file.rs +++ b/src/config_file.rs @@ -44,6 +44,8 @@ pub struct Config { pub header: Option, pub literal: Option, pub truncate_owner: Option, + pub max_shown: Option, + pub tree_filter: Option>, } #[derive(Eq, PartialEq, Debug, Deserialize)] @@ -129,6 +131,8 @@ impl Config { header: None, literal: None, truncate_owner: None, + max_shown: None, + tree_filter: None, } } @@ -362,6 +366,14 @@ truncate-owner: after: # String to be appended to a name if truncated. marker: "" +# == Max Shown == +# max number of items to display per directory level in tree layout. +# max-shown: 10 + +# == Tree Filter == +# only show entries matching these globs in tree layout. dirs always shown. +# tree-filter: +# - "*.rs" "#; #[cfg(test)] @@ -432,6 +444,8 @@ mod tests { after: None, marker: Some("".to_string()), }), + max_shown: None, + tree_filter: None, }, c ); diff --git a/src/display.rs b/src/display.rs index 842ffb248..ae5927842 100644 --- a/src/display.rs +++ b/src/display.rs @@ -241,12 +241,38 @@ fn inner_display_tree( tree_index: usize, ) -> Vec { let mut cells = Vec::new(); - let last_idx = metas.len(); - for (idx, meta) in metas.iter().enumerate() { + // apply tree filter: dirs always shown, non-dirs must match a glob + let filtered: Vec<&Meta> = if !flags.tree_filter.0.is_empty() { + metas + .iter() + .filter(|m| { + matches!(m.file_type, FileType::Directory { .. }) + || matches!(m.file_type, FileType::SymLink { is_dir: true }) + || flags.tree_filter.0.is_match(m.name.file_name()) + }) + .collect() + } else { + metas.iter().collect() + }; + + // truncate to max_shown + let (display_metas, truncated) = if let Some(n) = flags.max_shown.0 { + if filtered.len() > n { + (&filtered[..n], filtered.len() - n) + } else { + (filtered.as_slice(), 0usize) + } + } else { + (filtered.as_slice(), 0usize) + }; + + let last_idx = display_metas.len(); + + for (idx, meta) in display_metas.iter().enumerate() { + let is_last = truncated == 0 && idx + 1 == last_idx; let current_prefix = if tree_depth_prefix.0 > 0 { - if idx + 1 != last_idx { - // is last folder elem + if !is_last { format!("{}{} ", tree_depth_prefix.1, EDGE) } else { format!("{}{} ", tree_depth_prefix.1, CORNER) @@ -274,8 +300,7 @@ fn inner_display_tree( if let Some(content) = &meta.content { let new_prefix = if tree_depth_prefix.0 > 0 { - if idx + 1 != last_idx { - // is last folder elem + if !is_last { format!("{}{} ", tree_depth_prefix.1, LINE) } else { format!("{}{} ", tree_depth_prefix.1, BLANK) @@ -298,6 +323,30 @@ fn inner_display_tree( } } + if truncated > 0 { + let prefix = if tree_depth_prefix.0 > 0 { + format!("{}{} ", tree_depth_prefix.1, CORNER) + } else { + tree_depth_prefix.1.to_string() + }; + let summary_text = format!("{}... and {} more", prefix, truncated); + let colored_summary = colors.colorize(&summary_text, &Elem::TreeEdge).to_string(); + + for i in 0..flags.blocks.0.len() { + if i == tree_index { + cells.push(Cell { + width: get_visible_width(&colored_summary, flags.hyperlink == HyperlinkOption::Always), + contents: colored_summary.clone(), + }); + } else { + cells.push(Cell { + width: 0, + contents: String::new(), + }); + } + } + } + cells } @@ -987,4 +1036,102 @@ mod tests { drop(file); drop(link); } + + #[test] + fn test_tree_with_max_shown_truncation() { + let argv = ["lsd", "--tree", "--max-shown", "2"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let flags = Flags::configure_from(&cli, &Config::with_none()).unwrap(); + + let dir = assert_fs::TempDir::new().unwrap(); + dir.child("a").touch().unwrap(); + dir.child("b").touch().unwrap(); + dir.child("c").touch().unwrap(); + dir.child("d").touch().unwrap(); + + let mut metas = Meta::from_path(Path::new(dir.path()), false, PermissionFlag::Rwx) + .unwrap() + .recurse_into(42, &flags, None) + .unwrap() + .0 + .unwrap(); + sort(&mut metas, &sort::assemble_sorters(&flags)); + + let output = tree( + &metas, + &flags, + &Colors::new(color::ThemeOption::NoColor), + &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), + ); + + // first 2 items shown, summary line for remaining 2 + assert!(output.contains("... and 2 more"), "summary line missing"); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3, "expected 2 items + summary line"); + } + + #[test] + fn test_tree_with_tree_filter() { + let argv = ["lsd", "--tree", "--tree-filter", "*.rs"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let flags = Flags::configure_from(&cli, &Config::with_none()).unwrap(); + + let dir = assert_fs::TempDir::new().unwrap(); + dir.child("subdir").create_dir_all().unwrap(); + dir.child("main.rs").touch().unwrap(); + dir.child("readme.md").touch().unwrap(); + + let mut metas = Meta::from_path(Path::new(dir.path()), false, PermissionFlag::Rwx) + .unwrap() + .recurse_into(42, &flags, None) + .unwrap() + .0 + .unwrap(); + sort(&mut metas, &sort::assemble_sorters(&flags)); + + let output = tree( + &metas, + &flags, + &Colors::new(color::ThemeOption::NoColor), + &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), + ); + + assert!(output.contains("main.rs"), "matching file should be shown"); + assert!(output.contains("subdir"), "dirs should always be shown"); + assert!(!output.contains("readme.md"), "non-matching file should be hidden"); + } + + #[test] + fn test_tree_with_filter_and_max_shown() { + let argv = ["lsd", "--tree", "--tree-filter", "*.rs", "--max-shown", "1"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let flags = Flags::configure_from(&cli, &Config::with_none()).unwrap(); + + let dir = assert_fs::TempDir::new().unwrap(); + dir.child("a.rs").touch().unwrap(); + dir.child("b.rs").touch().unwrap(); + dir.child("c.md").touch().unwrap(); + + let mut metas = Meta::from_path(Path::new(dir.path()), false, PermissionFlag::Rwx) + .unwrap() + .recurse_into(42, &flags, None) + .unwrap() + .0 + .unwrap(); + sort(&mut metas, &sort::assemble_sorters(&flags)); + + let output = tree( + &metas, + &flags, + &Colors::new(color::ThemeOption::NoColor), + &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), + ); + + // c.md excluded by filter; a.rs shown; b.rs in "... and 1 more" + assert!(!output.contains("c.md"), "non-matching file should be hidden"); + assert!(output.contains("... and 1 more"), "summary should reflect filtered count"); + } } diff --git a/src/flags.rs b/src/flags.rs index 52e7a49a0..425f396ec 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -3,6 +3,7 @@ pub mod color; pub mod date; pub mod dereference; pub mod display; +mod glob_helpers; pub mod header; pub mod hyperlink; pub mod icons; @@ -10,6 +11,7 @@ pub mod ignore_globs; pub mod indicators; pub mod layout; pub mod literal; +pub mod max_shown; pub mod permission; pub mod recursion; pub mod size; @@ -17,6 +19,7 @@ pub mod sorting; pub mod symlink_arrow; pub mod symlinks; pub mod total_size; +pub mod tree_filter; pub mod truncate_owner; pub use blocks::Blocks; @@ -34,6 +37,7 @@ pub use ignore_globs::IgnoreGlobs; pub use indicators::Indicators; pub use layout::Layout; pub use literal::Literal; +pub use max_shown::MaxShown; pub use permission::PermissionFlag; pub use recursion::Recursion; pub use size::SizeFlag; @@ -44,6 +48,7 @@ pub use sorting::Sorting; pub use symlink_arrow::SymlinkArrow; pub use symlinks::NoSymlink; pub use total_size::TotalSize; +pub use tree_filter::TreeFilter; pub use truncate_owner::TruncateOwner; use crate::app::Cli; @@ -77,6 +82,8 @@ pub struct Flags { pub header: Header, pub literal: Literal, pub truncate_owner: TruncateOwner, + pub max_shown: MaxShown, + pub tree_filter: TreeFilter, } impl Flags { @@ -108,6 +115,8 @@ impl Flags { header: Header::configure_from(cli, config), literal: Literal::configure_from(cli, config), truncate_owner: TruncateOwner::configure_from(cli, config), + max_shown: MaxShown::configure_from(cli, config), + tree_filter: TreeFilter::configure_from(cli, config)?, }) } } diff --git a/src/flags/glob_helpers.rs b/src/flags/glob_helpers.rs new file mode 100644 index 000000000..7e41c442f --- /dev/null +++ b/src/flags/glob_helpers.rs @@ -0,0 +1,13 @@ +use clap::Error; +use clap::error::ErrorKind; +use globset::{Glob, GlobSet, GlobSetBuilder}; + +pub fn create_glob(pattern: &str) -> Result { + Glob::new(pattern).map_err(|err| Error::raw(ErrorKind::ValueValidation, err)) +} + +pub fn create_glob_set(builder: &GlobSetBuilder) -> Result { + builder + .build() + .map_err(|err| Error::raw(ErrorKind::ValueValidation, err)) +} diff --git a/src/flags/ignore_globs.rs b/src/flags/ignore_globs.rs index 1800dcf66..035462513 100644 --- a/src/flags/ignore_globs.rs +++ b/src/flags/ignore_globs.rs @@ -4,9 +4,9 @@ use crate::app::Cli; use crate::config_file::Config; +use super::glob_helpers::{create_glob, create_glob_set}; use clap::Error; -use clap::error::ErrorKind; -use globset::{Glob, GlobSet, GlobSetBuilder}; +use globset::{GlobSet, GlobSetBuilder}; /// The struct holding a [GlobSet] and methods to build it. #[derive(Clone, Debug)] @@ -47,7 +47,7 @@ impl IgnoreGlobs { let mut glob_set_builder = GlobSetBuilder::new(); for value in &cli.ignore_glob { - match Self::create_glob(value) { + match create_glob(value) { Ok(glob) => { glob_set_builder.add(glob); } @@ -55,7 +55,7 @@ impl IgnoreGlobs { } } - Some(Self::create_glob_set(&glob_set_builder).map(Self)) + Some(create_glob_set(&glob_set_builder).map(Self)) } /// Get a potential [IgnoreGlobs] from a [Config]. @@ -70,7 +70,7 @@ impl IgnoreGlobs { let mut glob_set_builder = GlobSetBuilder::new(); for glob in globs { - match Self::create_glob(glob) { + match create_glob(glob) { Ok(glob) => { glob_set_builder.add(glob); } @@ -78,24 +78,9 @@ impl IgnoreGlobs { } } - Some(Self::create_glob_set(&glob_set_builder).map(Self)) + Some(create_glob_set(&glob_set_builder).map(Self)) } - /// Create a [Glob] from a provided pattern. - /// - /// This method is mainly a helper to wrap the handling of potential errors. - fn create_glob(pattern: &str) -> Result { - Glob::new(pattern).map_err(|err| Error::raw(ErrorKind::ValueValidation, err)) - } - - /// Create a [GlobSet] from a provided [GlobSetBuilder]. - /// - /// This method is mainly a helper to wrap the handling of potential errors. - fn create_glob_set(builder: &GlobSetBuilder) -> Result { - builder - .build() - .map_err(|err| Error::raw(ErrorKind::ValueValidation, err)) - } } /// The default value of `IgnoreGlobs` is the empty [GlobSet], returned by [GlobSet::empty()]. diff --git a/src/flags/max_shown.rs b/src/flags/max_shown.rs new file mode 100644 index 000000000..db1957927 --- /dev/null +++ b/src/flags/max_shown.rs @@ -0,0 +1,89 @@ +//! This module defines the [MaxShown] flag. To set it up from [Cli], a [Config] and its +//! [Default] value, use the [configure_from](MaxShown::configure_from) method via [Configurable]. + +use super::Configurable; + +use crate::app::Cli; +use crate::config_file::Config; + +/// max number of items to show per directory level in tree layout +#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)] +pub struct MaxShown(pub Option); + +impl Configurable for MaxShown { + /// Get a potential `MaxShown` value from [Cli]. + /// + /// If the "max-shown" argument has been passed, this returns a `MaxShown` with `Some(n)` + /// in a [Some]. Otherwise this returns [None]. + fn from_cli(cli: &Cli) -> Option { + cli.max_shown.map(|n| Self(Some(n))) + } + + /// Get a potential `MaxShown` value from a [Config]. + /// + /// If `Config::max_shown` has a value, this returns it wrapped in `MaxShown(Some(n))` + /// in a [Some]. Otherwise this returns [None]. + fn from_config(config: &Config) -> Option { + config.max_shown.map(|n| Self(Some(n))) + } +} + +#[cfg(test)] +mod test { + use clap::Parser; + + use super::MaxShown; + + use crate::app::Cli; + use crate::config_file::Config; + use crate::flags::Configurable; + + #[test] + fn test_from_cli_none() { + let argv = ["lsd"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert_eq!(None, MaxShown::from_cli(&cli)); + } + + #[test] + fn test_from_cli_some() { + let argv = ["lsd", "--max-shown", "3"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert_eq!(Some(MaxShown(Some(3))), MaxShown::from_cli(&cli)); + } + + #[test] + fn test_from_config_none() { + assert_eq!(None, MaxShown::from_config(&Config::with_none())); + } + + #[test] + fn test_from_config_some() { + let mut c = Config::with_none(); + c.max_shown = Some(5); + assert_eq!(Some(MaxShown(Some(5))), MaxShown::from_config(&c)); + } + + #[test] + fn test_default() { + assert_eq!(MaxShown(None), MaxShown::default()); + } + + #[test] + fn test_configure_from_cli_takes_precedence() { + let argv = ["lsd", "--max-shown", "7"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let mut c = Config::with_none(); + c.max_shown = Some(2); + assert_eq!(MaxShown(Some(7)), MaxShown::configure_from(&cli, &c)); + } + + #[test] + fn test_configure_from_config_fallback() { + let argv = ["lsd"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let mut c = Config::with_none(); + c.max_shown = Some(4); + assert_eq!(MaxShown(Some(4)), MaxShown::configure_from(&cli, &c)); + } +} diff --git a/src/flags/tree_filter.rs b/src/flags/tree_filter.rs new file mode 100644 index 000000000..7da51a13e --- /dev/null +++ b/src/flags/tree_filter.rs @@ -0,0 +1,142 @@ +//! This module defines the [TreeFilter]. To set it up from [Cli], a [Config] and its +//! [Default] value, use the [configure_from](TreeFilter::configure_from) method. + +use crate::app::Cli; +use crate::config_file::Config; + +use super::glob_helpers::{create_glob, create_glob_set}; +use clap::Error; +use globset::{GlobSet, GlobSetBuilder}; + +/// the struct holding a [GlobSet] for inclusive tree filtering +#[derive(Clone, Debug)] +pub struct TreeFilter(pub GlobSet); + +impl TreeFilter { + /// Returns a value from either [Cli], a [Config] or a [Default] value. The first value + /// that is not [None] is used. The order of precedence for the value used is: + /// - [from_cli](TreeFilter::from_cli) + /// - [from_config](TreeFilter::from_config) + /// - [Default::default] + /// + /// # Errors + /// + /// If either of the [Glob::new] or [GlobSetBuilder::build] methods return an [Err]. + pub fn configure_from(cli: &Cli, config: &Config) -> Result { + if let Some(value) = Self::from_cli(cli) { + return value; + } + + if let Some(value) = Self::from_config(config) { + return value; + } + + Ok(Default::default()) + } + + /// Get a potential [TreeFilter] from [Cli]. + /// + /// If the "tree-filter" argument has been passed, this returns a [Result] in a [Some] with + /// either the built [TreeFilter] or an [Error]. If the argument has not been passed, returns [None]. + fn from_cli(cli: &Cli) -> Option> { + if cli.tree_filter.is_empty() { + return None; + } + + let mut builder = GlobSetBuilder::new(); + + for value in &cli.tree_filter { + match create_glob(value) { + Ok(glob) => { + builder.add(glob); + } + Err(err) => return Some(Err(err)), + } + } + + Some(create_glob_set(&builder).map(Self)) + } + + /// Get a potential [TreeFilter] from a [Config]. + /// + /// If `Config::tree_filter` contains an array of strings, each value is used to build + /// the [GlobSet]. If the build succeeds, returns [TreeFilter] in a [Some]. If the + /// config does not contain such a key, returns [None]. + fn from_config(config: &Config) -> Option> { + let globs = config.tree_filter.as_ref()?; + let mut builder = GlobSetBuilder::new(); + + for glob in globs { + match create_glob(glob) { + Ok(glob) => { + builder.add(glob); + } + Err(err) => return Some(Err(err)), + } + } + + Some(create_glob_set(&builder).map(Self)) + } + +} + +/// the default value of `TreeFilter` is the empty [GlobSet], returned by [GlobSet::empty()]. +impl Default for TreeFilter { + fn default() -> Self { + Self(GlobSet::empty()) + } +} + +#[cfg(test)] +mod test { + use clap::Parser; + + use super::TreeFilter; + + use crate::app::Cli; + use crate::config_file::Config; + + // tests use match instead of assert_eq because clap::Error does not implement PartialEq. + // no tests for actual GlobSet contents since GlobSet does not implement PartialEq. + + #[test] + fn test_configuration_from_none() { + let argv = ["lsd"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert!(matches!( + TreeFilter::configure_from(&cli, &Config::with_none()), + Ok(..) + )); + } + + #[test] + fn test_configuration_from_args() { + let argv = ["lsd", "--tree-filter", "*.rs"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert!(matches!( + TreeFilter::configure_from(&cli, &Config::with_none()), + Ok(..) + )); + } + + #[test] + fn test_configuration_from_config() { + let argv = ["lsd"]; + let cli = Cli::try_parse_from(argv).unwrap(); + let mut c = Config::with_none(); + c.tree_filter = Some(vec!["*.rs".into()]); + assert!(matches!(TreeFilter::configure_from(&cli, &c), Ok(..))); + } + + #[test] + fn test_from_cli_none() { + let argv = ["lsd"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert!(TreeFilter::from_cli(&cli).is_none()); + } + + #[test] + fn test_from_config_none() { + assert!(TreeFilter::from_config(&Config::with_none()).is_none()); + } +}