Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/lsd.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich
`--sort <WORD>...`
: Sort by WORD instead of name [possible values: size, time, version, extension, git]

`--respect-locale`
: Respect locale when sorting by name.

`-U`, `--no-sort`
: Do not sort. List entries in directory order

Expand Down
3 changes: 3 additions & 0 deletions doc/samples/config-sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ sorting:
# When "classic" is set, this is set to "none".
# Possible values: first, last, none
dir-grouping: none
# Whether to respect locale when sorting by name.
# Possible values: false, true
respect-locale: false

# == No Symlink ==
# Whether to omit showing symlink targets
Expand Down
4 changes: 4 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ pub struct Cli {
#[arg(short, long)]
pub reverse: bool,

/// Use locale-aware sorting for names
#[arg(long)]
pub respect_locale: bool,

/// Sort the directories then the files
#[arg(long, value_name = "MODE", value_parser = ["none", "first", "last"])]
pub group_dirs: Option<String>,
Expand Down
5 changes: 5 additions & 0 deletions src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub struct Sorting {
pub column: Option<SortColumn>,
pub reverse: Option<bool>,
pub dir_grouping: Option<DirGrouping>,
pub respect_locale: Option<bool>,
}

#[derive(Eq, PartialEq, Debug, Deserialize)]
Expand Down Expand Up @@ -324,6 +325,9 @@ sorting:
# When "classic" is set, this is set to "none".
# Possible values: first, last, none
dir-grouping: none
# Whether to respect locale when sorting by name.
# Possible values: false, true
respect-locale: false

# == No Symlink ==
# Whether to omit showing symlink targets
Expand Down Expand Up @@ -421,6 +425,7 @@ mod tests {
column: Some(SortColumn::Name),
reverse: Some(false),
dir_grouping: Some(DirGrouping::None),
respect_locale: Some(false),
}),
no_symlink: Some(false),
total_size: Some(false),
Expand Down
43 changes: 43 additions & 0 deletions src/flags/sorting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub struct Sorting {
pub column: SortColumn,
pub order: SortOrder,
pub dir_grouping: DirGrouping,
pub respect_locale: bool,
}

impl Sorting {
Expand All @@ -25,12 +26,40 @@ impl Sorting {
let column = SortColumn::configure_from(cli, config);
let order = SortOrder::configure_from(cli, config);
let dir_grouping = DirGrouping::configure_from(cli, config);
let respect_locale = Self::respect_locale_from(cli, config);
Self {
column,
order,
dir_grouping,
respect_locale,
}
}

/// Get the "respect_locale" boolean from [Cli], a [Config] or the [Default] value. The first
/// value that is not [None] is used. The order of precedence for the value used is:
/// - [respect_locale_from_cli](Sorting::respect_locale_from_cli)
/// - [Config.sorting.respect_locale]
/// - [Default::default]
fn respect_locale_from(cli: &Cli, config: &Config) -> bool {
if let Some(value) = Self::respect_locale_from_cli(cli) {
return value;
}
if let Some(sorting) = &config.sorting {
if let Some(respect_locale) = sorting.respect_locale {
return respect_locale;
}
}

Default::default()
}

/// Get a potential "respect_locale" boolean from [Cli].
///
/// If the "respect_locale" argument is passed, this returns `true` in a [Some]. Otherwise this
/// returns [None].
fn respect_locale_from_cli(cli: &Cli) -> Option<bool> {
if cli.respect_locale { Some(true) } else { None }
}
}

/// The flag showing which column to use for sorting.
Expand Down Expand Up @@ -293,6 +322,7 @@ mod test_sort_column {
column: None,
reverse: None,
dir_grouping: None,
respect_locale: None,
});

assert_eq!(None, SortColumn::from_config(&c));
Expand All @@ -305,6 +335,7 @@ mod test_sort_column {
column: Some(SortColumn::Extension),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::Extension), SortColumn::from_config(&c));
}
Expand All @@ -316,6 +347,7 @@ mod test_sort_column {
column: Some(SortColumn::Name),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::Name), SortColumn::from_config(&c));
}
Expand All @@ -327,6 +359,7 @@ mod test_sort_column {
column: Some(SortColumn::Time),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::Time), SortColumn::from_config(&c));
}
Expand All @@ -338,6 +371,7 @@ mod test_sort_column {
column: Some(SortColumn::Size),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::Size), SortColumn::from_config(&c));
}
Expand All @@ -349,6 +383,7 @@ mod test_sort_column {
column: Some(SortColumn::Version),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::Version), SortColumn::from_config(&c));
}
Expand All @@ -360,6 +395,7 @@ mod test_sort_column {
column: Some(SortColumn::GitStatus),
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_config(&c));
}
Expand Down Expand Up @@ -409,6 +445,7 @@ mod test_sort_order {
column: None,
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(None, SortOrder::from_config(&c));
}
Expand All @@ -420,6 +457,7 @@ mod test_sort_order {
column: None,
reverse: Some(true),
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortOrder::Reverse), SortOrder::from_config(&c));
}
Expand All @@ -431,6 +469,7 @@ mod test_sort_order {
column: None,
reverse: Some(false),
dir_grouping: None,
respect_locale: None,
});
assert_eq!(Some(SortOrder::Default), SortOrder::from_config(&c));
}
Expand Down Expand Up @@ -513,6 +552,7 @@ mod test_dir_grouping {
column: None,
reverse: None,
dir_grouping: Some(DirGrouping::First),
respect_locale: None,
});
assert_eq!(Some(DirGrouping::First), DirGrouping::from_config(&c));
}
Expand All @@ -524,6 +564,7 @@ mod test_dir_grouping {
column: None,
reverse: None,
dir_grouping: Some(DirGrouping::Last),
respect_locale: None,
});
assert_eq!(Some(DirGrouping::Last), DirGrouping::from_config(&c));
}
Expand All @@ -535,6 +576,7 @@ mod test_dir_grouping {
column: None,
reverse: None,
dir_grouping: None,
respect_locale: None,
});
assert_eq!(None, DirGrouping::from_config(&c));
}
Expand All @@ -546,6 +588,7 @@ mod test_dir_grouping {
column: None,
reverse: None,
dir_grouping: Some(DirGrouping::Last),
respect_locale: None,
});
c.classic = Some(true);
assert_eq!(Some(DirGrouping::None), DirGrouping::from_config(&c));
Expand Down
137 changes: 137 additions & 0 deletions src/meta/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ impl Name {
pub fn file_type(&self) -> FileType {
self.file_type
}

// Locale-aware comparison using strcoll for matching the behavior of `ls`.
#[cfg(unix)]
pub fn cmp_locale(&self, other: &Self) -> Ordering {
use std::ffi::CString;
use std::sync::Once;
static LOCALE_INIT: Once = Once::new();
LOCALE_INIT.call_once(|| unsafe {
libc::setlocale(libc::LC_ALL, b"\0".as_ptr() as *const libc::c_char);
});
let a = CString::new(self.name.as_str()).unwrap_or_default();
let b = CString::new(other.name.as_str()).unwrap_or_default();
let result = unsafe { libc::strcoll(a.as_ptr(), b.as_ptr()) };
result.cmp(&0)
}

#[cfg(not(unix))]
pub fn cmp_locale(&self, other: &Self) -> Ordering {
self.cmp(other)
}
}

impl Ord for Name {
Expand Down Expand Up @@ -230,6 +250,8 @@ mod test {
use crate::url::Url;
use crossterm::style::{Color, Stylize};
use std::cmp::Ordering;
#[cfg(unix)]
use std::ffi::CString;
use std::fs::{self, File};
#[cfg(unix)]
use std::os::unix::fs::symlink;
Expand Down Expand Up @@ -593,6 +615,121 @@ mod test {
assert!(name_1 == name_2);
}

// Helper function for locale testing,
// triggers the Once inside cmp_locale then override locale for testing
#[cfg(unix)]
fn set_locale_for_cmp(locale: &str) {
// Trigger Once::call_once inside cmp_locale so it won't override later
let dummy = Name::new(
Path::new("x"),
FileType::File {
uid: false,
exec: false,
},
);
let _ = dummy.cmp_locale(&dummy);
let loc = CString::new(locale).unwrap();
unsafe {
libc::setlocale(libc::LC_ALL, loc.as_ptr());
}
}

#[test]
#[serial_test::serial]
#[cfg(unix)]
fn test_cmp_locale_c_name_struct() {
set_locale_for_cmp("C");

let name_upper = Name::new(
Path::new("B"),
FileType::File {
uid: false,
exec: false,
},
);
let name_lower = Name::new(
Path::new("a"),
FileType::File {
uid: false,
exec: false,
},
);
let name_dot_lower = Name::new(
Path::new(".a"),
FileType::File {
uid: false,
exec: false,
},
);
let name_dot_upper = Name::new(
Path::new(".A"),
FileType::File {
uid: false,
exec: false,
},
);

// In C locale: "B" (0x42) < "a" (0x61) by byte order
assert_eq!(name_upper.cmp_locale(&name_lower), Ordering::Less);
assert_eq!(name_lower.cmp_locale(&name_upper), Ordering::Greater);

// In C locale: dot (0x2E) < uppercase (0x41+) < lowercase (0x61+)
// ".a" < "a", ".a" < "A", ".A" < "a", ".A" < "A"
assert_eq!(name_dot_lower.cmp_locale(&name_lower), Ordering::Less);
assert_eq!(name_dot_lower.cmp_locale(&name_upper), Ordering::Less);
assert_eq!(name_dot_upper.cmp_locale(&name_lower), Ordering::Less);
assert_eq!(name_dot_upper.cmp_locale(&name_upper), Ordering::Less);
}

#[test]
#[serial_test::serial]
#[cfg(unix)]
fn test_cmp_locale_en_us_utf8_name_struct() {
set_locale_for_cmp("en_US.UTF-8");

let name_upper = Name::new(
Path::new("B"),
FileType::File {
uid: false,
exec: false,
},
);
let name_lower = Name::new(
Path::new("a"),
FileType::File {
uid: false,
exec: false,
},
);
let name_dot_lower = Name::new(
Path::new(".a"),
FileType::File {
uid: false,
exec: false,
},
);
let name_dot_upper = Name::new(
Path::new(".A"),
FileType::File {
uid: false,
exec: false,
},
);

// In en_US.UTF-8: "a" sorts before "B" (alphabetic order)
assert_eq!(name_lower.cmp_locale(&name_upper), Ordering::Less);
assert_eq!(name_upper.cmp_locale(&name_lower), Ordering::Greater);

// In en_US.UTF-8: dot is mostly ignored for primary sort key
// ".a" < "a", ".a" < "A" (dot as secondary tiebreaker)
assert_eq!(name_dot_lower.cmp_locale(&name_lower), Ordering::Less);
assert_eq!(name_dot_lower.cmp_locale(&name_upper), Ordering::Less);
// ".A" > "a" (primary key: A vs a, A comes after a in en_US)
assert_eq!(name_dot_upper.cmp_locale(&name_lower), Ordering::Greater);
// ".A" < "A" (same letter, dot version sorts first)
assert_eq!(name_dot_upper.cmp_locale(&name_upper), Ordering::Less);
}

#[test]
fn test_parent_relative_path() {
let name = Name::new(
Expand Down
Loading