Skip to main content

f00_format/
color.rs

1//! Color engine using the `lscolors` crate fully from `LS_COLORS`.
2
3use std::path::Path;
4
5use f00_core::{Entry, EntryKind};
6use lscolors::{LsColors, Style};
7use nu_ansi_term::Color;
8
9/// Color engine wrapping `LS_COLORS` / defaults.
10#[derive(Clone)]
11pub struct Colorizer {
12    enabled: bool,
13    ls: LsColors,
14}
15
16impl Colorizer {
17    /// Build from environment (`LS_COLORS`) when enabled.
18    pub fn new(enabled: bool) -> Self {
19        Self {
20            enabled,
21            ls: LsColors::from_env().unwrap_or_default(),
22        }
23    }
24
25    /// Build with an explicit LS_COLORS string (tests / overrides).
26    pub fn from_ls_colors(enabled: bool, ls_colors: &str) -> Self {
27        Self {
28            enabled,
29            ls: LsColors::from_string(ls_colors),
30        }
31    }
32
33    /// Access the underlying `LsColors` map.
34    pub fn ls_colors(&self) -> &LsColors {
35        &self.ls
36    }
37
38    pub fn enabled(&self) -> bool {
39        self.enabled
40    }
41
42    /// Colorize a display name for an entry using full LS_COLORS matching.
43    ///
44    /// Hidden / dotfile names (leading `.`) are painted **darker grey** so they
45    /// recede next to normal entries (eza-style). Applies whenever colors are on,
46    /// including under `--gnu` when color is forced on.
47    pub fn paint_name(&self, entry: &Entry, text: &str) -> String {
48        if !self.enabled {
49            return text.to_string();
50        }
51
52        if is_dotfile_name(&entry.name) {
53            return Color::DarkGray.paint(text).to_string();
54        }
55
56        // Prefer path + metadata style when possible.
57        if let Some(style) = self.style_for_entry(entry) {
58            return paint_with_ls_style(text, style);
59        }
60
61        // Fallback by kind
62        match entry.kind {
63            EntryKind::Directory => Color::Blue.bold().paint(text).to_string(),
64            EntryKind::Symlink => Color::Cyan.paint(text).to_string(),
65            EntryKind::File if entry_is_exec(entry) => Color::Green.bold().paint(text).to_string(),
66            EntryKind::File => text.to_string(),
67            EntryKind::Other => Color::Yellow.paint(text).to_string(),
68        }
69    }
70
71    /// Resolve LS_COLORS style for an entry (extension, type indicators, etc.).
72    pub fn style_for_entry<'a>(&'a self, entry: &'a Entry) -> Option<&'a Style> {
73        // Try path with metadata-like hints via style_for_path first.
74        if let Some(style) = self.ls.style_for_path(&entry.path) {
75            return Some(style);
76        }
77        // Extension / name patterns
78        if let Some(style) = self.ls.style_for_path(Path::new(&entry.name)) {
79            return Some(style);
80        }
81        // Indicator by kind
82        use lscolors::Indicator;
83        let indicator = match entry.kind {
84            EntryKind::Directory => Some(Indicator::Directory),
85            EntryKind::Symlink => Some(Indicator::SymbolicLink),
86            EntryKind::File if entry_is_exec(entry) => Some(Indicator::ExecutableFile),
87            EntryKind::File => Some(Indicator::RegularFile),
88            EntryKind::Other => {
89                #[cfg(unix)]
90                {
91                    match entry.mode & 0o170000 {
92                        0o010000 => Some(Indicator::FIFO),
93                        0o140000 => Some(Indicator::Socket),
94                        0o060000 => Some(Indicator::BlockDevice),
95                        0o020000 => Some(Indicator::CharacterDevice),
96                        _ => None,
97                    }
98                }
99                #[cfg(not(unix))]
100                {
101                    None
102                }
103            }
104        };
105        indicator.and_then(|i| self.ls.style_for_indicator(i))
106    }
107
108    pub fn paint_git_char(&self, ch: char) -> String {
109        if !self.enabled {
110            return ch.to_string();
111        }
112        let s = ch.to_string();
113        match ch {
114            'M' => Color::Yellow.bold().paint(s).to_string(),
115            'A' => Color::Green.bold().paint(s).to_string(),
116            'D' | 'U' => Color::Red.bold().paint(s).to_string(),
117            '?' => Color::Purple.paint(s).to_string(),
118            '!' => Color::DarkGray.paint(s).to_string(),
119            'R' => Color::Cyan.bold().paint(s).to_string(),
120            _ => s,
121        }
122    }
123
124    /// Whether modern multi-column long-format theming should run.
125    ///
126    /// On for friendly (non-GNU) mode when colors are enabled; off under `--gnu`.
127    pub fn modern_long_theme(&self, gnu_mode: bool) -> bool {
128        self.enabled && !gnu_mode
129    }
130
131    /// Permission string (e.g. `-rwxr-xr-x`) with type letter + rwx tinting.
132    pub fn paint_perms(&self, perms: &str, gnu_mode: bool) -> String {
133        if !self.modern_long_theme(gnu_mode) || perms.is_empty() {
134            return perms.to_string();
135        }
136        let mut out = String::with_capacity(perms.len() * 8);
137        for (i, ch) in perms.chars().enumerate() {
138            let painted = if i == 0 {
139                match ch {
140                    'd' => Color::Blue.bold().paint(ch.to_string()).to_string(),
141                    'l' => Color::Cyan.bold().paint(ch.to_string()).to_string(),
142                    'c' | 'b' => Color::Yellow.bold().paint(ch.to_string()).to_string(),
143                    'p' | 's' => Color::Purple.paint(ch.to_string()).to_string(),
144                    _ => Color::DarkGray.paint(ch.to_string()).to_string(),
145                }
146            } else {
147                match ch {
148                    'r' => Color::Yellow.paint(ch.to_string()).to_string(),
149                    'w' => Color::Red.paint(ch.to_string()).to_string(),
150                    'x' | 's' | 't' | 'S' | 'T' => {
151                        Color::Green.bold().paint(ch.to_string()).to_string()
152                    }
153                    '-' => Color::DarkGray.paint(ch.to_string()).to_string(),
154                    _ => ch.to_string(),
155                }
156            };
157            out.push_str(&painted);
158        }
159        out
160    }
161
162    /// Dim metadata (nlink, inode, blocks, context).
163    pub fn paint_meta(&self, text: &str, gnu_mode: bool) -> String {
164        if !self.modern_long_theme(gnu_mode) {
165            return text.to_string();
166        }
167        Color::DarkGray.paint(text).to_string()
168    }
169
170    /// Owner / group / author column.
171    pub fn paint_user(&self, text: &str, gnu_mode: bool) -> String {
172        if !self.modern_long_theme(gnu_mode) {
173            return text.to_string();
174        }
175        Color::Yellow.paint(text).to_string()
176    }
177
178    /// Size column: green → yellow → red by magnitude (bytes).
179    pub fn paint_size(&self, text: &str, bytes: u64, gnu_mode: bool) -> String {
180        if !self.modern_long_theme(gnu_mode) {
181            return text.to_string();
182        }
183        let style = if bytes >= 1_073_741_824 {
184            // ≥ 1 GiB
185            Color::Red.bold()
186        } else if bytes >= 10_485_760 {
187            // ≥ 10 MiB
188            Color::Yellow.bold()
189        } else if bytes >= 1_048_576 {
190            // ≥ 1 MiB
191            Color::Green.bold()
192        } else if bytes > 0 {
193            Color::Green.normal()
194        } else {
195            Color::DarkGray.normal()
196        };
197        style.paint(text).to_string()
198    }
199
200    /// Timestamp column.
201    pub fn paint_time(&self, text: &str, gnu_mode: bool) -> String {
202        if !self.modern_long_theme(gnu_mode) {
203            return text.to_string();
204        }
205        Color::Blue.paint(text).to_string()
206    }
207
208    /// Symlink name + arrow + target (name via LS_COLORS / cyan; target dim cyan).
209    pub fn paint_symlink_name(
210        &self,
211        entry: &Entry,
212        icon_and_name: &str,
213        arrow_and_target: &str,
214        gnu_mode: bool,
215    ) -> String {
216        if !self.modern_long_theme(gnu_mode) {
217            let full = format!("{icon_and_name}{arrow_and_target}");
218            return self.paint_name(entry, &full);
219        }
220        let name = self.paint_name(entry, icon_and_name);
221        if arrow_and_target.is_empty() {
222            return name;
223        }
224        // Split " -> target" style
225        let target = if let Some(rest) = arrow_and_target.strip_prefix(" -> ") {
226            format!(
227                " {} {}",
228                Color::DarkGray.paint("→"),
229                Color::Cyan.dimmed().paint(rest)
230            )
231        } else {
232            Color::DarkGray.paint(arrow_and_target).to_string()
233        };
234        format!("{name}{target}")
235    }
236}
237
238fn paint_with_ls_style(text: &str, style: &Style) -> String {
239    style.to_nu_ansi_term_style().paint(text).to_string()
240}
241
242/// Dotfiles / hidden entries: basename starts with `.` (includes `.` and `..`).
243fn is_dotfile_name(name: &str) -> bool {
244    name.starts_with('.')
245}
246
247fn entry_is_exec(entry: &Entry) -> bool {
248    #[cfg(unix)]
249    {
250        entry.mode & 0o111 != 0
251    }
252    #[cfg(not(unix))]
253    {
254        let _ = entry;
255        false
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use f00_core::{Entry, EntryKind, GitStatus};
263    use std::path::PathBuf;
264
265    fn file(name: &str) -> Entry {
266        Entry {
267            path: PathBuf::from(name),
268            name: name.into(),
269            kind: EntryKind::File,
270            size: 0,
271            modified: None,
272            created: None,
273            accessed: None,
274            changed: None,
275            mode: 0o644,
276            readonly: false,
277            symlink_target: None,
278            depth: 0,
279            git_status: GitStatus::Clean,
280            is_dir_header: false,
281            nlink: 1,
282            uid: 0,
283            gid: 0,
284            inode: 0,
285            blocks: 0,
286            owner: "u".into(),
287            group: "g".into(),
288            author: "u".into(),
289            context: String::new(),
290        }
291    }
292
293    #[test]
294    fn disabled_no_ansi() {
295        let c = Colorizer::from_ls_colors(false, "*.rs=01;31");
296        let e = file("main.rs");
297        assert_eq!(c.paint_name(&e, "main.rs"), "main.rs");
298    }
299
300    #[test]
301    fn ls_colors_extension() {
302        let c = Colorizer::from_ls_colors(true, "*.rs=01;31:");
303        let e = file("main.rs");
304        let painted = c.paint_name(&e, "main.rs");
305        // Should contain ANSI CSI when style applies.
306        assert!(painted.contains("main.rs"), "{painted:?}");
307        // Style applied → not equal plain (or equal if style empty — accept either with style present)
308        let _ = c.style_for_entry(&e);
309    }
310
311    #[test]
312    fn modern_theme_off_under_gnu() {
313        let c = Colorizer::from_ls_colors(true, "");
314        assert!(!c.modern_long_theme(true));
315        assert!(c.modern_long_theme(false));
316        assert_eq!(c.paint_perms("-rwxr-xr-x", true), "-rwxr-xr-x");
317        assert_ne!(c.paint_perms("-rwxr-xr-x", false), "-rwxr-xr-x");
318    }
319
320    #[test]
321    fn hidden_dotfiles_paint_dark_grey() {
322        let c = Colorizer::from_ls_colors(true, "*.rs=01;31:");
323        let hidden = file(".gitignore");
324        let normal = file("README.md");
325        let painted_h = c.paint_name(&hidden, ".gitignore");
326        let painted_n = c.paint_name(&normal, "README.md");
327        assert!(
328            painted_h.contains(".gitignore"),
329            "name preserved: {painted_h:?}"
330        );
331        // Dark gray uses an ANSI sequence; plain name alone would not.
332        assert_ne!(
333            painted_h, ".gitignore",
334            "hidden name should be styled, got {painted_h:?}"
335        );
336        // Hidden styling is independent of LS_COLORS extension rules.
337        assert!(
338            painted_h.contains('\u{1b}') || painted_h != painted_n,
339            "expected ANSI or distinct paint for hidden vs normal"
340        );
341        let off = Colorizer::from_ls_colors(false, "");
342        assert_eq!(off.paint_name(&hidden, ".gitignore"), ".gitignore");
343        assert!(is_dotfile_name("."));
344        assert!(is_dotfile_name(".."));
345        assert!(is_dotfile_name(".config"));
346        assert!(!is_dotfile_name("config"));
347    }
348
349    #[test]
350    fn modern_size_tints() {
351        let c = Colorizer::from_ls_colors(true, "");
352        let small = c.paint_size("1.0K", 1024, false);
353        let big = c.paint_size("2.0G", 2_147_483_648, false);
354        assert!(small.contains("1.0K"), "{small}");
355        assert!(big.contains("2.0G"), "{big}");
356        // Different colors → different escape sequences (or at least styled).
357        assert!(small.contains('\u{1b}') || small != "1.0K");
358        assert!(big.contains('\u{1b}') || big != "2.0G");
359    }
360}