From 1b9151491cb778df2846fba946b09f310d7ab29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christina=20S=C3=B8rensen?= Date: Thu, 9 Jul 2026 10:14:17 +0200 Subject: [PATCH] feat: add --short-nix to abbreviate Nix store hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A path component starting with a Nix store hash — exactly 32 characters of Nix's base32 alphabet followed by a dash — is displayed with the hash shortened to its first 8 characters plus an ellipsis, painted dim so the actual name stands out: vlkia5wk0svsikwv50554mh06iayg2m2-source.drv -> vlkia5wk…-source.drv This applies everywhere names are painted: listings in every view, symbolic link targets, parent paths of targets, and --absolute paths. The matcher requires the exact hash length and alphabet (which has no e, o, t, or u), so ordinary file names are never false-positively shortened. --- completions/fish/eza.fish | 1 + completions/nush/eza.nu | 1 + completions/pwsh/_eza.ps1 | 1 + completions/zsh/_eza | 1 + man/eza.1.md | 5 + src/options/file_name.rs | 12 ++ src/options/parser.rs | 1 + src/output/file_name.rs | 201 ++++++++++++++++++++- src/theme/mod.rs | 1 + tests/ptests/ptest_2439b7d68089135b.stdout | 1 + 10 files changed, 216 insertions(+), 9 deletions(-) diff --git a/completions/fish/eza.fish b/completions/fish/eza.fish index 95934d55..fe4728ab 100644 --- a/completions/fish/eza.fish +++ b/completions/fish/eza.fish @@ -37,6 +37,7 @@ complete -c eza -l icons -d "When to display icons" -x -a " never\t'Never display icons' " complete -c eza -l no-quotes -d "Don't quote file names with spaces" +complete -c eza -l short-nix -d "Abbreviate Nix store hashes in file names and paths" complete -c eza -l hyperlink -d "When to display entries as hyperlinks" -x -a " always\t'Always display entries as hyperlinks' auto\t'Display hyperlinks if standard output is a terminal' diff --git a/completions/nush/eza.nu b/completions/nush/eza.nu index 42b792a0..41101459 100644 --- a/completions/nush/eza.nu +++ b/completions/nush/eza.nu @@ -17,6 +17,7 @@ export extern "eza" [ --colour-scale-mode # Use gradient or fixed colors in --colour-scale --icons # When to display icons --no-quotes # Don't quote file names with spaces + --short-nix # Abbreviate Nix store hashes in file names and paths --hyperlink # When to display entries as hyperlinks --absolute # Display entries with their absolute path --follow-symlinks # Drill down into symbolic links that point to directories diff --git a/completions/pwsh/_eza.ps1 b/completions/pwsh/_eza.ps1 index 341fdf09..3ad639c3 100644 --- a/completions/pwsh/_eza.ps1 +++ b/completions/pwsh/_eza.ps1 @@ -176,6 +176,7 @@ Register-ArgumentCompleter -Native -CommandName 'eza' -ScriptBlock { # [CompletionResult]::new('--colour-scale-mode' ,'colorscalemode' , [CompletionResultType]::ParameterName, 'use gradient or fixed colors in --color-scale (fixed, gradient)') [CompletionResult]::new('--icons' ,'icons' , [CompletionResultType]::ParameterName, 'when to display icons (always, auto, never)') [CompletionResult]::new('--no-quotes' ,'noquotes' , [CompletionResultType]::ParameterName, 'don''t quote file names with spaces') + [CompletionResult]::new('--short-nix' ,'shortnix' , [CompletionResultType]::ParameterName, 'abbreviate Nix store hashes in file names and paths') [CompletionResult]::new('--hyperlink' ,'hyperlink' , [CompletionResultType]::ParameterName, 'when to display entries as hyperlinks (always, auto, never)') [CompletionResult]::new('--absolute' ,'absolute' , [CompletionResultType]::ParameterName, 'display entries with their absolute path (on, follow, off)') [CompletionResult]::new('--follow-symlinks' ,'followsymlinks' , [CompletionResultType]::ParameterName, 'drill down into symbolic links that point to directories') diff --git a/completions/zsh/_eza b/completions/zsh/_eza index fd712be5..fbdf3928 100644 --- a/completions/zsh/_eza +++ b/completions/zsh/_eza @@ -25,6 +25,7 @@ __eza() { --colo{,u}r-scale-mode"[Use gradient or fixed colors in --color-scale]:(mode):(fixed gradient)" \ --icons="[When to display icons]:(when):(always auto automatic never)" \ --no-quotes"[Don't quote filenames with spaces]" \ + --short-nix"[Abbreviate Nix store hashes in file names and paths]" \ --hyperlink="[When to display entries as hyperlinks]:(when):(always auto automatic never)" \ --absolute"[Display entries with their absolute path]:(mode):(on follow off)" \ --follow-symlinks"[Drill down into symbolic links that point to directories]" \ diff --git a/man/eza.1.md b/man/eza.1.md index 225b9bb9..9d0976a3 100644 --- a/man/eza.1.md +++ b/man/eza.1.md @@ -128,6 +128,11 @@ When used without a value, defaults to ‘`automatic`’. `--no-quotes` : Don't quote file names with spaces. +`--short-nix` +: Abbreviate Nix store hashes in file names and paths. + +: A path component beginning with a Nix store hash — exactly 32 characters of Nix’s base32 alphabet followed by a dash, like `vlkia5wk0svsikwv50554mh06iayg2m2-source.drv` — is displayed with the hash shortened to its first 8 characters and an ellipsis, painted dim so the name stands out: `vlkia5wk…-source.drv`. This applies to listed names, symbolic link targets, and absolute paths. + `--hyperlink=WHEN` : Display entries as hyperlinks diff --git a/src/options/file_name.rs b/src/options/file_name.rs index f591df6c..1e8c1613 100644 --- a/src/options/file_name.rs +++ b/src/options/file_name.rs @@ -25,6 +25,7 @@ impl Options { let embed_hyperlinks = EmbedHyperlinks::deduce(matches); let absolute = *matches.get_one("absolute").unwrap(); + let short_nix = matches.get_flag("short-nix"); Ok(Self { classify, @@ -32,6 +33,7 @@ impl Options { quote_style, embed_hyperlinks, absolute, + short_nix, is_a_tty, }) } @@ -272,8 +274,18 @@ mod tests { quote_style: QuoteStyle::QuoteSpaces, embed_hyperlinks: EmbedHyperlinks::Never, absolute: Absolute::Off, + short_nix: false, is_a_tty: true, }) ); } + + #[test] + fn deduce_options_short_nix() { + assert!( + Options::deduce(&mock_cli(vec!["--short-nix"]), &MockVars::default(), true) + .unwrap() + .short_nix + ); + } } diff --git a/src/options/parser.rs b/src/options/parser.rs index fda2133b..5e8ba7da 100644 --- a/src/options/parser.rs +++ b/src/options/parser.rs @@ -96,6 +96,7 @@ pub fn get_command() -> clap::Command { .value_parser(value_parser!(ShowWhen)) .default_missing_value("auto")) .arg(arg!(--"no-quotes" "don't quote file names with spaces")) + .arg(arg!(--"short-nix" "abbreviate Nix store hashes in file names and paths")) .next_help_heading("FILTERING OPTIONS") .arg(arg!(-a --all... "show hidden files. Use this twice to also show the '.' and '..' directories")) diff --git a/src/output/file_name.rs b/src/output/file_name.rs index bbb8a7c9..afa6756e 100644 --- a/src/output/file_name.rs +++ b/src/output/file_name.rs @@ -36,10 +36,73 @@ pub struct Options { /// Whether to display files with their absolute path. pub absolute: Absolute, + /// Whether to abbreviate Nix store hashes in file names and paths. + pub short_nix: bool, + /// Whether we are in a console or redirecting the output pub is_a_tty: bool, } +/// How many characters of a Nix store hash to keep when `--short-nix` +/// abbreviates it. Eight is enough to stay recognisable and unambiguous at +/// a glance, like a short git commit id. +const SHORT_NIX_HASH_LENGTH: usize = 8; + +/// Whether this byte belongs to Nix’s base32 alphabet, which deliberately +/// omits `e`, `o`, `t`, and `u` to avoid accidentally spelling words. +const fn is_nix_base32(b: u8) -> bool { + matches!(b, b'0'..=b'9' | b'a'..=b'd' | b'f'..=b'n' | b'p'..=b's' | b'v'..=b'z') +} + +/// If `component` starts with a Nix store hash — exactly 32 characters of +/// Nix base32 followed by a dash — return the hash and the rest. +fn split_nix_hash(component: &str) -> Option<(&str, &str)> { + let (hash, rest) = component.split_at_checked(32)?; + if rest.starts_with('-') && hash.bytes().all(is_nix_base32) { + Some((hash, rest)) + } else { + None + } +} + +/// One piece of a display name: either an abbreviated Nix store hash, to be +/// painted dim, or ordinary text painted in the file’s usual style. +#[derive(Debug, PartialEq, Eq)] +enum NameSegment { + Hash(String), + Text(String), +} + +/// Split a display name (which may be a whole path) into segments, replacing +/// every path component’s leading Nix store hash with its abbreviation. +fn shorten_nix_segments(name: &str) -> Vec { + let mut segments = Vec::new(); + let mut text = String::new(); + + for (i, component) in name.split('/').enumerate() { + if i > 0 { + text.push('/'); + } + if let Some((hash, rest)) = split_nix_hash(component) { + if !text.is_empty() { + segments.push(NameSegment::Text(std::mem::take(&mut text))); + } + segments.push(NameSegment::Hash(format!( + "{}…", + &hash[..SHORT_NIX_HASH_LENGTH] + ))); + text.push_str(rest); + } else { + text.push_str(component); + } + } + + if !text.is_empty() { + segments.push(NameSegment::Text(text)); + } + segments +} + impl Options { /// Create a new `FileName` that prints the given file’s name, painting it /// with the remaining arguments. @@ -284,6 +347,7 @@ impl FileName<'_, '_, C> { embed_hyperlinks: EmbedHyperlinks::Never, is_a_tty: self.options.is_a_tty, absolute: Absolute::Off, + short_nix: self.options.short_nix, }; let target_name = FileName { @@ -354,8 +418,19 @@ impl FileName<'_, '_, C> { .paint(std::path::MAIN_SEPARATOR.to_string()), ); } else if coconut >= 1 { + let mut parent_str = parent.to_string_lossy().to_string(); + if self.options.short_nix { + // Parent paths are painted in one dim style already, so the + // hashes just get abbreviated in place. + parent_str = shorten_nix_segments(&parent_str) + .into_iter() + .map(|segment| match segment { + NameSegment::Hash(s) | NameSegment::Text(s) => s, + }) + .collect(); + } escape( - parent.to_string_lossy().to_string(), + parent_str, bits, self.colours.symlink_path(), self.colours.control_char(), @@ -435,13 +510,33 @@ impl FileName<'_, '_, C> { display_hyperlink = true; } - escape( - self.display_name(), - &mut bits, - file_style, - self.colours.control_char(), - self.options.quote_style, - ); + let display_name = self.display_name(); + if self.options.short_nix { + // Abbreviated store hashes get painted dim, so the part of the + // name that a human actually reads is the part that stands out. + for segment in shorten_nix_segments(&display_name) { + match segment { + NameSegment::Hash(hash) => { + bits.push(self.colours.nix_hash().paint(hash)); + } + NameSegment::Text(text) => escape( + text, + &mut bits, + file_style, + self.colours.control_char(), + self.options.quote_style, + ), + } + } + } else { + escape( + display_name, + &mut bits, + file_style, + self.colours.control_char(), + self.options.quote_style, + ); + } if display_hyperlink { bits.push(ANSIString::from(escape::HYPERLINK_CLOSING)); @@ -504,7 +599,17 @@ impl FileName<'_, '_, C> { /// For grid's use, to cover the case of hyperlink escape sequences #[must_use] pub fn bare_utf8_width(&self) -> usize { - UnicodeWidthStr::width(self.file.name.as_str()) + if self.options.short_nix { + let shortened: String = shorten_nix_segments(&self.file.name) + .into_iter() + .map(|segment| match segment { + NameSegment::Hash(s) | NameSegment::Text(s) => s, + }) + .collect(); + UnicodeWidthStr::width(shortened.as_str()) + } else { + UnicodeWidthStr::width(self.file.name.as_str()) + } } } @@ -528,6 +633,9 @@ pub trait Colours: FiletypeColours { /// The style to paint a non-displayable control character in a filename. fn control_char(&self) -> Style; + /// The style to paint an abbreviated Nix store hash with `--short-nix`. + fn nix_hash(&self) -> Style; + /// The style to paint a non-displayable control character in a filename, /// when the filename is being displayed as a broken link target. fn broken_control_char(&self) -> Style; @@ -542,3 +650,78 @@ pub trait Colours: FiletypeColours { fn style_override(&self, file: &File<'_>) -> Option; } + +#[cfg(test)] +mod test { + use super::*; + + const HASH: &str = "vlkia5wk0svsikwv50554mh06iayg2m2"; + + #[test] + fn splits_a_store_file_name() { + let name = format!("{HASH}-source.drv"); + assert_eq!(split_nix_hash(&name), Some((HASH, "-source.drv"))); + } + + #[test] + fn rejects_wrong_length_and_alphabet() { + // Too short, no dash, and a hash containing characters outside + // Nix’s base32 set (it has no e, o, t, or u). + assert_eq!(split_nix_hash("abc-source.drv"), None); + assert_eq!(split_nix_hash(HASH), None); + assert_eq!( + split_nix_hash("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-source.drv"), + None + ); + } + + #[test] + fn plain_names_pass_through_untouched() { + assert_eq!( + shorten_nix_segments("Cargo.toml"), + vec![NameSegment::Text("Cargo.toml".into())] + ); + } + + #[test] + fn shortens_a_bare_store_name() { + assert_eq!( + shorten_nix_segments(&format!("{HASH}-source.drv")), + vec![ + NameSegment::Hash("vlkia5wk…".into()), + NameSegment::Text("-source.drv".into()), + ] + ); + } + + #[test] + fn shortens_every_component_of_a_path() { + let path = format!("/nix/store/{HASH}-foo/bin/{HASH}-bar"); + assert_eq!( + shorten_nix_segments(&path), + vec![ + NameSegment::Text("/nix/store/".into()), + NameSegment::Hash("vlkia5wk…".into()), + NameSegment::Text("-foo/bin/".into()), + NameSegment::Hash("vlkia5wk…".into()), + NameSegment::Text("-bar".into()), + ] + ); + } + + #[test] + fn hash_mid_component_is_not_shortened() { + let name = format!("prefix-{HASH}-source.drv"); + assert_eq!( + shorten_nix_segments(&name), + vec![NameSegment::Text(name.clone())] + ); + } + + #[test] + fn multibyte_names_are_split_safely() { + // A name whose 32-char boundary would fall inside a multi-byte + // character must not panic. + assert_eq!(split_nix_hash("ααααααααααααααααα-x"), None); + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index d2e48b6b..6064e844 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -469,6 +469,7 @@ impl FileNameColours for Theme { fn broken_filename(&self) -> Style { apply_overlay(self.ui.broken_symlink(), self.ui.broken_path_overlay()) } fn control_char(&self) -> Style { self.ui.control_char() } fn broken_control_char(&self) -> Style { apply_overlay(self.ui.control_char(), self.ui.broken_path_overlay()) } + fn nix_hash(&self) -> Style { self.ui.punctuation() } fn executable_file(&self) -> Style { self.ui.filekinds.unwrap_or_default().executable() } fn mount_point(&self) -> Style { self.ui.filekinds.unwrap_or_default().mount_point() } diff --git a/tests/ptests/ptest_2439b7d68089135b.stdout b/tests/ptests/ptest_2439b7d68089135b.stdout index 60833651..a1c2d528 100644 --- a/tests/ptests/ptest_2439b7d68089135b.stdout +++ b/tests/ptests/ptest_2439b7d68089135b.stdout @@ -29,6 +29,7 @@ DISPLAY OPTIONS: --icons [] when to display icons [possible values: always, auto, never] --hyperlink [] when to display entries as hyperlinks [possible values: always, auto, never] --no-quotes don't quote file names with spaces + --short-nix abbreviate Nix store hashes in file names and paths FILTERING OPTIONS: -a, --all... show hidden files. Use this twice to also show the '.' and '..' directories