1use std::path::Path;
4
5use f00_core::{Entry, EntryKind};
6use lscolors::{LsColors, Style};
7use nu_ansi_term::Color;
8
9#[derive(Clone)]
11pub struct Colorizer {
12 enabled: bool,
13 ls: LsColors,
14}
15
16impl Colorizer {
17 pub fn new(enabled: bool) -> Self {
19 Self {
20 enabled,
21 ls: LsColors::from_env().unwrap_or_default(),
22 }
23 }
24
25 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 pub fn ls_colors(&self) -> &LsColors {
35 &self.ls
36 }
37
38 pub fn enabled(&self) -> bool {
39 self.enabled
40 }
41
42 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 if let Some(style) = self.style_for_entry(entry) {
58 return paint_with_ls_style(text, style);
59 }
60
61 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 pub fn style_for_entry<'a>(&'a self, entry: &'a Entry) -> Option<&'a Style> {
73 if let Some(style) = self.ls.style_for_path(&entry.path) {
75 return Some(style);
76 }
77 if let Some(style) = self.ls.style_for_path(Path::new(&entry.name)) {
79 return Some(style);
80 }
81 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 pub fn modern_long_theme(&self, gnu_mode: bool) -> bool {
128 self.enabled && !gnu_mode
129 }
130
131 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 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 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 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 Color::Red.bold()
186 } else if bytes >= 10_485_760 {
187 Color::Yellow.bold()
189 } else if bytes >= 1_048_576 {
190 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 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 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 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
242fn 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 assert!(painted.contains("main.rs"), "{painted:?}");
307 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 assert_ne!(
333 painted_h, ".gitignore",
334 "hidden name should be styled, got {painted_h:?}"
335 );
336 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 assert!(small.contains('\u{1b}') || small != "1.0K");
358 assert!(big.contains('\u{1b}') || big != "2.0G");
359 }
360}