diff --git a/.gitignore b/.gitignore index 4888cb786..77faa0ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target out.md **/*.rs.bk +.claude-flow/ diff --git a/src/icon.rs b/src/icon.rs index 27fb22735..64e503b4a 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -48,6 +48,8 @@ impl Icons { _ => { if let Some(icon) = t.name.get(name.file_name().to_lowercase().as_str()) { icon + } else if let Some(icon) = t.get_icon_by_prefix(name.file_name()) { + icon } else if let Some(icon) = name .extension() .and_then(|ext| t.extension.get(ext.to_lowercase().as_str())) @@ -234,4 +236,33 @@ mod test { assert_eq!(icon_str, format!("{}{}", file_icon, icon.icon_separator)); } } + + #[test] + fn get_icon_by_prefix() { + let tmp_dir = tempdir().expect("failed to create temp dir"); + + for (prefix, file_icon) in &IconTheme::get_default_icons_by_prefix() { + let file_path = tmp_dir.path().join(format!("{prefix}.lsd.0")); + File::create(&file_path).expect("failed to create file"); + let meta = Meta::from_path(&file_path, false, PermissionFlag::Rwx).unwrap(); + + let icon = Icons::new(false, IconOption::Always, FlagTheme::Fancy, " ".to_string()); + let icon_str = icon.get(&meta.name); + + assert_eq!(icon_str, format!("{}{}", file_icon, icon.icon_separator)); + } + } + + #[test] + fn get_icon_by_prefix_overrides_extension() { + let tmp_dir = tempdir().expect("failed to create temp dir"); + let file_path = tmp_dir.path().join("log.txt"); + File::create(&file_path).expect("failed to create file"); + let meta = Meta::from_path(&file_path, false, PermissionFlag::Rwx).unwrap(); + + let icon = Icons::new(false, IconOption::Always, FlagTheme::Fancy, " ".to_string()); + let icon_str = icon.get(&meta.name); + + assert_eq!(icon_str, format!("{}{}", "\u{f18d}", icon.icon_separator)); + } } diff --git a/src/theme/icon.rs b/src/theme/icon.rs index 0c89054e0..2a59e84f8 100644 --- a/src/theme/icon.rs +++ b/src/theme/icon.rs @@ -1,22 +1,13 @@ use serde::Deserialize; use std::collections::HashMap; -enum ByFilename { - Name, - Extension, -} - -fn deserialize_by_filename<'de, D>( +fn deserialize_with_default<'de, D>( deserializer: D, - by: ByFilename, + default: HashMap, ) -> Result, D::Error> where D: serde::de::Deserializer<'de>, { - let default = match by { - ByFilename::Name => IconTheme::get_default_icons_by_name(), - ByFilename::Extension => IconTheme::get_default_icons_by_extension(), - }; HashMap::<_, _>::deserialize(deserializer) .map(|input| default.into_iter().chain(input).collect()) } @@ -25,14 +16,21 @@ fn deserialize_by_name<'de, D>(deserializer: D) -> Result, { - deserialize_by_filename(deserializer, ByFilename::Name) + deserialize_with_default(deserializer, IconTheme::get_default_icons_by_name()) } fn deserialize_by_extension<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::de::Deserializer<'de>, { - deserialize_by_filename(deserializer, ByFilename::Extension) + deserialize_with_default(deserializer, IconTheme::get_default_icons_by_extension()) +} + +fn deserialize_by_prefix<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + deserialize_with_default(deserializer, IconTheme::get_default_icons_by_prefix()) } #[derive(Debug, Deserialize, PartialEq, Eq)] @@ -44,6 +42,8 @@ pub struct IconTheme { pub name: HashMap, #[serde(deserialize_with = "deserialize_by_extension")] pub extension: HashMap, + #[serde(deserialize_with = "deserialize_by_prefix")] + pub prefix: HashMap, pub filetype: ByType, } @@ -69,6 +69,7 @@ impl Default for IconTheme { IconTheme { name: Self::get_default_icons_by_name(), extension: Self::get_default_icons_by_extension(), + prefix: Self::get_default_icons_by_prefix(), filetype: ByType::default(), } } @@ -113,6 +114,7 @@ impl IconTheme { IconTheme { name: HashMap::new(), extension: HashMap::new(), + prefix: HashMap::new(), filetype: ByType::unicode(), } } @@ -752,6 +754,31 @@ impl IconTheme { .map(|&s| (s.0.to_owned(), s.1.to_owned())) .collect::>() } + + // pub only for testing in icons.rs + pub fn get_default_icons_by_prefix() -> HashMap { + // Note: prefixes must be lower-case + [ + ("log", "\u{f18d}"), // "" + ] + .iter() + .map(|&s| (s.0.to_owned(), s.1.to_owned())) + .collect::>() + } + + /// Return the icon for the longest matching prefix of `name`. + /// + /// Prefix keys are expected to be lower-case. The file name is + /// lower-cased before matching. If several prefixes match, the longest + /// one wins; ties are broken lexicographically so the result is stable. + pub fn get_icon_by_prefix(&self, name: &str) -> Option<&String> { + let name = name.to_lowercase(); + self.prefix + .iter() + .filter(|(prefix, _)| name.starts_with(prefix.as_str())) + .max_by(|(a, _), (b, _)| a.len().cmp(&b.len()).then_with(|| a.cmp(b))) + .map(|(_, icon)| icon) + } } #[cfg(test)] @@ -770,6 +797,8 @@ extension: go:  hs:  rs:  +prefix: + log:  filetype: dir:  file:  @@ -865,4 +894,27 @@ filetype: // the default icon  should be used for *.go files. assert_eq!(theme.extension.get("go").unwrap(), "\u{e627}"); } + + #[test] + fn test_custom_icon_by_prefix() { + // When a user sets to use 📦-icon for files starting with `cargo`, + let theme: IconTheme = Theme::with_yaml("prefix:\n cargo: 📦").unwrap(); + // 📦-icon should be returned for files starting with `cargo`. + assert_eq!(theme.get_icon_by_prefix("cargo.lock").unwrap(), "📦"); + } + + #[test] + fn test_default_icon_by_prefix_with_custom_entry() { + // When a user sets to use 📦-icon for files starting with `cargo`, + let theme: IconTheme = Theme::with_yaml("prefix:\n cargo: 📦").unwrap(); + // the default icon  should still be used for files starting with `log`. + assert_eq!(theme.get_icon_by_prefix("log.lsd.0").unwrap(), "\u{f18d}"); + } + + #[test] + fn test_prefix_longest_match_wins() { + let theme: IconTheme = Theme::with_yaml("prefix:\n cargo: 📦\n cargo.lock: 🔒").unwrap(); + assert_eq!(theme.get_icon_by_prefix("cargo.lock").unwrap(), "🔒"); + assert_eq!(theme.get_icon_by_prefix("cargo.toml").unwrap(), "📦"); + } } diff --git a/tests/integration.rs b/tests/integration.rs index 72442331c..b6d112692 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -688,6 +688,22 @@ fn test_upper_case_ext_icon_match() { .stdout(predicate::str::contains("\u{f410}")); } +#[cfg(unix)] +#[test] +fn test_prefix_icon_match() { + let dir = tempdir(); + dir.child("log.lsd.0").touch().unwrap(); + let test_file = dir.path().join("log.lsd.0"); + + cmd() + .arg("--icon") + .arg("always") + .arg("--ignore-config") + .arg(test_file) + .assert() + .stdout(predicate::str::contains("\u{f18d}")); +} + #[cfg(unix)] #[test] fn test_truncate_owner() {