diff --git a/doc/lsd.md b/doc/lsd.md index 78a120465..2431855b8 100644 --- a/doc/lsd.md +++ b/doc/lsd.md @@ -93,7 +93,7 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich : Natural sort of (version) numbers within text `--blocks ...` -: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode, git] +: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode, links, lines, git] `--color ...` : When to use terminal colours [default: auto] [possible values: always, auto, never] diff --git a/doc/samples/config-sample.yaml b/doc/samples/config-sample.yaml index 3da640e5d..42ed95a1f 100644 --- a/doc/samples/config-sample.yaml +++ b/doc/samples/config-sample.yaml @@ -9,7 +9,7 @@ classic: false # == Blocks == # This specifies the columns and their order when using the long and the tree # layout. -# Possible values: permission, user, group, context, size, date, name, inode, links, git +# Possible values: permission, user, group, context, size, date, name, inode, links, lines, git blocks: - permission - user diff --git a/src/app.rs b/src/app.rs index c8d80c7cb..87d8340c4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -137,7 +137,7 @@ pub struct Cli { #[arg( long, value_delimiter = ',', - value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links", "git"], + value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links", "lines", "lines_value", "git"], )] pub blocks: Vec, diff --git a/src/core.rs b/src/core.rs index b867cadfc..d827c6510 100644 --- a/src/core.rs +++ b/src/core.rs @@ -164,6 +164,13 @@ impl Core { } } + // Only calculate the total lines of a directory if it will be displayed + if self.flags.total_size.0 && self.flags.blocks.displays_lines() { + for meta in &mut meta_list.iter_mut() { + meta.calculate_total_lines(); + } + } + (meta_list, exit_code) } diff --git a/src/display.rs b/src/display.rs index 842ffb248..fc5592d60 100644 --- a/src/display.rs +++ b/src/display.rs @@ -352,6 +352,31 @@ fn get_output( Some(links) => links.render(colors), None => colorize_missing("?"), }), + Block::Lines => { + let pad = if Layout::Tree == flags.layout && 0 == tree.0 && 0 == i { + None + } else { + Some(padding_rules.get(&Block::LinesValue).copied().unwrap_or(0)) + }; + block_vec.push(match &meta.lines { + Some(lines) => { + let value_str = lines.value_string(); + let rendered = lines.render(colors); + if let Some(align) = pad { + let left_pad = " ".repeat(align.saturating_sub(value_str.len())); + let padded = format!("{}{}", left_pad, rendered); + colors.colorize(padded, &Elem::FileSmall) + } else { + rendered + } + } + None => colorize_missing("?"), + }) + } + Block::LinesValue => block_vec.push(match &meta.lines { + Some(lines) => lines.render(colors), + None => colorize_missing("?"), + }), Block::Permission => { block_vec.extend([ meta.file_type.render(colors), @@ -486,6 +511,32 @@ fn detect_size_lengths(metas: &[Meta], flags: &Flags) -> usize { max_value_length } +fn detect_lines_lengths(metas: &[Meta], flags: &Flags) -> usize { + let mut max_value_length: usize = 0; + + for meta in metas { + let value_len = match &meta.lines { + Some(lines) => lines.value_string().len(), + None => 1, // "-" character + }; + + if value_len > max_value_length { + max_value_length = value_len; + } + + if Layout::Tree == flags.layout { + if let Some(subs) = &meta.content { + let sub_length = detect_lines_lengths(subs, flags); + if sub_length > max_value_length { + max_value_length = sub_length; + } + } + } + } + + max_value_length +} + fn get_padding_rules(metas: &[Meta], flags: &Flags) -> HashMap { let mut padding_rules: HashMap = HashMap::new(); @@ -495,6 +546,12 @@ fn get_padding_rules(metas: &[Meta], flags: &Flags) -> HashMap { padding_rules.insert(Block::SizeValue, size_val); } + if flags.blocks.0.contains(&Block::Lines) { + let lines_val = detect_lines_lengths(metas, flags); + + padding_rules.insert(Block::LinesValue, lines_val); + } + padding_rules } diff --git a/src/flags/blocks.rs b/src/flags/blocks.rs index f3576a7f1..ce1a8d7d7 100644 --- a/src/flags/blocks.rs +++ b/src/flags/blocks.rs @@ -50,6 +50,11 @@ impl Blocks { self.0.contains(&Block::Size) } + /// Checks whether `self` contains a [Block] of variant [Lines](Block::Lines). + pub fn displays_lines(&self) -> bool { + self.0.contains(&Block::Lines) + } + /// Inserts a [Block] of variant [INode](Block::Context), if `self` does not already contain a /// [Block] of that variant. The positioning will be best-effort approximation of coreutils /// ls position for a security context @@ -194,6 +199,8 @@ pub enum Block { Name, INode, Links, + Lines, + LinesValue, GitStatus, } @@ -202,6 +209,8 @@ impl Block { match self { Block::INode => "INode", Block::Links => "Links", + Block::Lines => "Lines", + Block::LinesValue => "LinesValue", Block::Permission => "Permissions", Block::User => "User", Block::Group => "Group", @@ -230,6 +239,8 @@ impl TryFrom<&str> for Block { "name" => Ok(Self::Name), "inode" => Ok(Self::INode), "links" => Ok(Self::Links), + "lines" => Ok(Self::Lines), + "lines_value" => Ok(Self::LinesValue), "git" => Ok(Self::GitStatus), _ => Err(format!("Not a valid block name: {string}")), } @@ -582,10 +593,22 @@ mod test_block { assert_eq!(Ok(Block::Context), Block::try_from("context")); } + #[test] + fn test_lines() { + assert_eq!(Ok(Block::Lines), Block::try_from("lines")); + } + + #[test] + fn test_lines_value() { + assert_eq!(Ok(Block::LinesValue), Block::try_from("lines_value")); + } + #[test] fn test_block_headers() { assert_eq!(Block::INode.get_header(), "INode"); assert_eq!(Block::Links.get_header(), "Links"); + assert_eq!(Block::Lines.get_header(), "Lines"); + assert_eq!(Block::LinesValue.get_header(), "LinesValue"); assert_eq!(Block::Permission.get_header(), "Permissions"); assert_eq!(Block::User.get_header(), "User"); assert_eq!(Block::Group.get_header(), "Group"); diff --git a/src/meta/lines.rs b/src/meta/lines.rs new file mode 100644 index 000000000..6fdfcc539 --- /dev/null +++ b/src/meta/lines.rs @@ -0,0 +1,185 @@ +use crate::color::{ColoredString, Colors, Elem}; +use std::fs::{File, Metadata}; +use std::io::{BufRead, BufReader, Read}; +use std::path::Path; + +/// Maximum file size to scan for line counting (10MB). +/// Files larger than this will not have their lines counted to avoid performance issues. +const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; + +/// Size of buffer to check for binary files (8KB). +/// We check this many bytes at the start of a file for null bytes to detect binary files. +const BINARY_CHECK_SIZE: usize = 8192; + +/// Represents the line count of a file. +/// +/// For regular text files, contains Some(count). For directories, binary files, +/// files that are too large, or files that cannot be read, contains None. +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub struct Lines { + count: Option, +} + +impl Lines { + /// Create a Lines instance from a total count. + /// + /// This is used when calculating total lines for directories, + /// where we sum up the line counts of all contained files. + pub fn from_total(count: u64) -> Self { + Self { count: Some(count) } + } + + /// Create a Lines instance by counting lines in a file. + /// + /// Returns None for: + /// - Directories and non-regular files + /// - Files larger than MAX_FILE_SIZE + /// - Binary files (detected by null bytes) + /// - Files that cannot be read + pub fn from_path(path: &Path, metadata: &Metadata) -> Self { + // Only count lines for regular files + if !metadata.is_file() { + return Self { count: None }; + } + + // Skip files that are too large to avoid performance issues + if metadata.len() > MAX_FILE_SIZE { + return Self { count: None }; + } + + // Attempt to count lines, returning None on any error + match Self::count_lines(path) { + Ok(count) => Self { count: Some(count) }, + Err(_) => Self { count: None }, + } + } + + /// Count the number of lines in a file. + /// + /// Returns 0 for binary files (detected by null bytes in first 8KB). + /// Returns the actual line count for text files. + fn count_lines(path: &Path) -> std::io::Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + + // Check if file is binary by scanning for null bytes + let mut buffer = vec![0; BINARY_CHECK_SIZE]; + let bytes_read = reader.read(&mut buffer)?; + + // Binary files contain null bytes - return 0 to indicate this + if buffer[..bytes_read].contains(&0) { + return Ok(0); + } + + // Reopen file to count lines from the beginning + drop(reader); + let file = File::open(path)?; + let reader = BufReader::new(file); + + // Count lines using BufReader's lines iterator + let mut count = 0u64; + for _ in reader.lines() { + count += 1; + } + + Ok(count) + } + + /// Render the line count with appropriate coloring. + /// + /// Uses file size color scheme: + /// - Small files (<100 lines): FileSmall color + /// - Medium files (100-999 lines): FileMedium color + /// - Large files (>=1000 lines): FileLarge color + /// - Binary/unreadable files: NoAccess color (displays "-") + pub fn render(&self, colors: &Colors) -> ColoredString { + match self.count { + Some(0) => colors.colorize('-', &Elem::NoAccess), + Some(c) => { + let elem = if c >= 1000 { + &Elem::FileLarge + } else if c >= 100 { + &Elem::FileMedium + } else { + &Elem::FileSmall + }; + colors.colorize(c.to_string(), elem) + } + None => colors.colorize('-', &Elem::NoAccess), + } + } + + /// Get the line count as a string for alignment calculations. + /// + /// Returns "-" for binary files, directories, and unreadable files. + pub fn value_string(&self) -> String { + match self.count { + Some(0) => String::from("-"), + Some(c) => c.to_string(), + None => String::from("-"), + } + } +} + +#[cfg(test)] +mod tests { + use super::Lines; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_lines_empty_file() { + let file = NamedTempFile::new().unwrap(); + let meta = file.path().metadata().unwrap(); + let lines = Lines::from_path(file.path(), &meta); + assert_eq!(lines.count, Some(0)); + } + + #[test] + fn test_lines_text_file() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "line 1").unwrap(); + writeln!(file, "line 2").unwrap(); + writeln!(file, "line 3").unwrap(); + file.flush().unwrap(); + + let meta = file.path().metadata().unwrap(); + let lines = Lines::from_path(file.path(), &meta); + assert_eq!(lines.count, Some(3)); + } + + #[test] + fn test_lines_binary_file() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(&[0u8, 1u8, 2u8, 0u8]).unwrap(); + file.flush().unwrap(); + + let meta = file.path().metadata().unwrap(); + let lines = Lines::from_path(file.path(), &meta); + assert_eq!(lines.count, Some(0)); // Binary files return 0 + } + + #[test] + fn test_lines_from_total() { + let lines = Lines::from_total(42); + assert_eq!(lines.count, Some(42)); + } + + #[test] + fn test_value_string_text_file() { + let lines = Lines::from_total(123); + assert_eq!(lines.value_string(), "123"); + } + + #[test] + fn test_value_string_binary_file() { + let lines = Lines { count: Some(0) }; + assert_eq!(lines.value_string(), "-"); + } + + #[test] + fn test_value_string_none() { + let lines = Lines { count: None }; + assert_eq!(lines.value_string(), "-"); + } +} diff --git a/src/meta/mod.rs b/src/meta/mod.rs index 81b9dbcc5..8bd282f5b 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -4,6 +4,7 @@ mod filetype; pub mod git_file_status; mod indicator; mod inode; +mod lines; mod links; mod locale; pub mod name; @@ -24,6 +25,7 @@ pub use self::filetype::FileType; pub use self::git_file_status::GitFileStatus; pub use self::indicator::Indicator; pub use self::inode::INode; +pub use self::lines::Lines; pub use self::links::Links; pub use self::name::Name; pub use self::owner::{Cache as OwnerCache, Owner}; @@ -54,6 +56,7 @@ pub struct Meta { pub indicator: Indicator, pub inode: Option, pub links: Option, + pub lines: Option, pub content: Option>, pub access_control: Option, pub git_status: Option, @@ -258,6 +261,82 @@ impl Meta { } } + pub fn calculate_total_lines(&mut self) { + if self.lines.is_none() { + return; + } + + if let FileType::Directory { .. } = self.file_type { + if let Some(metas) = &mut self.content { + let mut lines_accumulated = 0u64; + for x in &mut metas.iter_mut() { + // must not count the lines of '.' and '..', or will be infinite loop + if x.name.name == "." || x.name.name == ".." { + continue; + } + + x.calculate_total_lines(); + lines_accumulated += match &x.lines { + Some(lines) => match lines.value_string().as_str() { + "-" => 0, // Skip binary files and errors + s => s.parse::().unwrap_or(0), + }, + None => 0, + }; + } + self.lines = Some(Lines::from_total(lines_accumulated)); + } else { + // possibility that 'depth' limited the recursion in 'recurse_into' + self.lines = Some(Lines::from_total(Meta::calculate_total_file_lines( + &self.path, + ))); + } + } + } + + fn calculate_total_file_lines(path: &Path) -> u64 { + let metadata = path.symlink_metadata(); + let metadata = match metadata { + Ok(meta) => meta, + Err(err) => { + print_error!("{}: {}.", path.display(), err); + return 0; + } + }; + let file_type = metadata.file_type(); + if file_type.is_file() { + // Count lines for this file + let lines = Lines::from_path(path, &metadata); + match lines.value_string().as_str() { + "-" => 0, // Binary files or errors + s => s.parse::().unwrap_or(0), + } + } else if file_type.is_dir() { + let mut lines = 0u64; + + let entries = match path.read_dir() { + Ok(entries) => entries, + Err(err) => { + print_error!("{}: {}.", path.display(), err); + return lines; + } + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(err) => { + print_error!("{}: {}.", path.display(), err); + continue; + } + }; + lines += Meta::calculate_total_file_lines(&path); + } + lines + } else { + 0 + } + } + pub fn from_path( path: &Path, dereference: bool, @@ -334,12 +413,13 @@ impl Meta { let name = Name::new(path, file_type); - let (inode, links, size, date, owner, permissions_or_attributes, access_control) = + let (inode, links, lines, size, date, owner, permissions_or_attributes, access_control) = match broken_link { - true => (None, None, None, None, None, None, None), + true => (None, None, None, None, None, None, None, None), false => ( Some(INode::from(&metadata)), Some(Links::from(&metadata)), + Some(Lines::from_path(path, &metadata)), Some(Size::from(&metadata)), Some(Date::from(&metadata)), Some(owner), @@ -351,6 +431,7 @@ impl Meta { Ok(Self { inode, links, + lines, path: path.to_path_buf(), symlink: SymLink::from(path), size, diff --git a/tests/integration.rs b/tests/integration.rs index 72442331c..a12372244 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -829,3 +829,93 @@ fn test_multiple_files() { .assert() .stdout(predicate::str::is_match(".").unwrap()); } + +#[test] +fn test_list_lines_with_text_files() { + let dir = tempdir(); + + let file1 = dir.child("short.txt"); + file1.write_str("line1\nline2\n").unwrap(); + + let file2 = dir.child("empty.txt"); + file2.touch().unwrap(); + + cmd() + .arg("--blocks") + .arg("lines,name") + .arg("--ignore-config") + .arg(dir.path()) + .assert() + .success(); +} + +#[test] +fn test_list_lines_value_block() { + let dir = tempdir(); + + dir.child("test.txt").write_str("a\nb\nc\n").unwrap(); + + cmd() + .arg("--blocks") + .arg("lines_value,name") + .arg("--ignore-config") + .arg(dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("3")); +} + +#[test] +fn test_tree_with_lines() { + let dir = tempdir(); + + dir.child("dir").create_dir_all().unwrap(); + dir.child("dir/file.txt") + .write_str("line1\nline2\n") + .unwrap(); + + cmd() + .arg("--tree") + .arg("--blocks") + .arg("lines,name") + .arg("--ignore-config") + .arg(dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("2")); +} + +#[test] +fn test_lines_with_binary_file() { + let dir = tempdir(); + let binary_file = dir.child("binary.dat"); + binary_file.write_binary(&[0u8, 1u8, 2u8, 0u8]).unwrap(); + + cmd() + .arg("--blocks") + .arg("lines,name") + .arg("--ignore-config") + .arg(dir.path()) + .assert() + .success(); +} + +#[test] +fn test_lines_alignment_in_tree() { + let dir = tempdir(); + + dir.child("dir").create_dir_all().unwrap(); + dir.child("short.txt").write_str("a\n").unwrap(); + dir.child("dir/long.txt") + .write_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + .unwrap(); + + cmd() + .arg("--tree") + .arg("--blocks") + .arg("lines,name") + .arg("--ignore-config") + .arg(dir.path()) + .assert() + .success(); +}