1use crate::color::{Color, ColorChoice};
3use crate::config;
4use crate::git::status;
5use crate::git::{Git, status::Status};
6pub use builder::Builder;
7pub use charset::Charset;
8pub use entry::Entry;
9use entry::attributes::{Attributes, FileAttributes};
10use owo_colors::AnsiColors;
11use owo_colors::OwoColorize;
12use std::fmt::Display;
13use std::io::{self, Write, stdout};
14use std::path::{Path, PathBuf};
15
16mod builder;
17mod charset;
18pub mod entry;
19
20pub struct Tree<'git, 'charset, P: AsRef<Path>> {
22 root: P,
24 git: Option<&'git Git>,
26 max_level: Option<usize>,
28 charset: Charset<'charset>,
30 color_choice: ColorChoice,
32 config: Option<config::Main>,
36 icons: Option<config::Icons>,
40 colors: Option<config::Colors>,
44}
45
46impl<'git, 'charset, P> Tree<'git, 'charset, P>
47where
48 P: AsRef<Path>,
49{
50 const DEFAULT_FILE_ICON: &'static str = "\u{f0214}"; const DEFAULT_EXECUTABLE_ICON: &'static str = "\u{f070e}"; const DEFAULT_DIRECTORY_ICON: &'static str = "\u{f024b}"; const DEFAULT_SYMLINK_ICON: &'static str = "\u{cf481}"; const EMPTY_ICON: &'static str = " ";
61
62 const DEFAULT_FILE_COLOR: Option<Color> = None;
64 const DEFAULT_EXECUTABLE_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Green));
66 const DEFAULT_DIRECTORY_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Blue));
68 const DEFAULT_SYMLINK_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Cyan));
70
71 #[inline]
73 pub fn write_to_stdout(&self) -> crate::Result<()>
74 where
75 P: AsRef<Path>,
76 {
77 let mut stdout = stdout();
78 self.write(&mut stdout)?;
79 Ok(())
80 }
81
82 pub fn write<W>(&self, writer: &mut W) -> io::Result<()>
84 where
85 W: Write,
86 {
87 let Ok(entry) = Entry::new(&self.root) else {
88 let path = self.root.as_ref();
91 Self::write_path(writer, path)?;
92 return writeln!(writer);
93 };
94 self.write_depth(writer, entry, 0)?;
95 writer.flush()
96 }
97
98 fn write_depth<W, P2>(&self, writer: &mut W, entry: Entry<P2>, depth: usize) -> io::Result<()>
100 where
101 W: Write,
102 P2: AsRef<Path>,
103 {
104 let path = entry.path();
105
106 self.write_entry(writer, &entry, depth == 0)?;
108
109 writeln!(writer)?;
110 if !path.is_dir() {
111 return Ok(());
112 }
113
114 let entries = match path.read_dir() {
117 Ok(entries) => entries.filter_map(Result::ok),
118 Err(_) => return Ok(()),
119 };
120 let entries = {
121 let entries = entries.map(|entry| entry.path()).map(Entry::new);
122 let entries = entries.filter_map(Result::ok);
125
126 let entries = entries.filter(|entry| !self.should_skip_entry(entry));
129
130 let mut entries = entries.collect::<Vec<_>>();
133 entries.sort_by_key(|entry| {
134 let path = entry.path();
135 path.to_path_buf()
136 });
137 entries
138 };
139 if self.max_level.map(|max| depth >= max).unwrap_or(false) {
140 return Ok(());
141 }
142
143 for entry in entries {
144 self.write_indentation(writer, depth)?;
145 write!(writer, "{}", self.charset.depth)?;
146 self.write_depth(writer, entry, depth + 1)?;
147 }
148
149 Ok(())
150 }
151
152 fn write_entry<W, P2>(&self, writer: &mut W, entry: &Entry<P2>, is_top: bool) -> io::Result<()>
154 where
155 W: Write,
156 P2: AsRef<Path>,
157 {
158 let path = entry.path();
159 self.write_statuses(writer, path)?;
160
161 let icon = self.get_icon(entry);
162 self.write_colorized_for_entry(entry, writer, icon)?;
163 write!(writer, " ")?;
165
166 let is_ignored = !is_top && self.is_path_ignored(path);
173
174 let path = if is_top {
175 path.as_os_str()
176 } else {
177 path.file_name()
181 .expect("A directory entry should always have a file name")
182 };
183
184 if !is_ignored {
185 Self::write_path(writer, path)
186 } else {
187 const TEXT_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Black));
188 self.color_choice
189 .write_to(writer, path.display(), TEXT_COLOR, None)
190 }
191 }
192
193 fn write_path<W, P2>(writer: &mut W, path: P2) -> io::Result<()>
195 where
196 W: Write,
197 P2: AsRef<Path>,
198 {
199 let path = path.as_ref();
200 writer.write_all(path.as_os_str().as_encoded_bytes())
201 }
202
203 fn write_indentation<W>(&self, writer: &mut W, level: usize) -> io::Result<()>
205 where
206 W: Write,
207 {
208 for _ in 0..level {
209 write!(writer, "{}", self.charset.breadth)?;
210 }
211 Ok(())
212 }
213
214 fn should_skip_entry<P2>(&self, entry: &Entry<P2>) -> bool
220 where
221 P2: AsRef<Path>,
222 {
223 let path = entry.path();
224 let is_hidden = entry.is_hidden() || self.is_path_ignored(path);
225 self.config
226 .as_ref()
227 .and_then(|config| config.should_skip(entry, is_hidden).transpose().ok())
228 .flatten()
229 .unwrap_or(is_hidden)
230 }
231
232 fn is_path_ignored<P2>(&self, path: P2) -> bool
234 where
235 P2: AsRef<Path>,
236 {
237 self.git
238 .and_then(|git| {
239 let path = self
242 .clean_path_for_git2(path)
243 .expect("Should be able to resolve path relative to git root");
244 git.is_ignored(path).ok()
245 })
246 .unwrap_or(false)
247 }
248
249 fn get_icon<P2>(&self, entry: &Entry<P2>) -> String
251 where
252 P2: AsRef<Path>,
253 {
254 let default_choice = match entry.attributes() {
255 Attributes::Directory(_) => Self::DEFAULT_DIRECTORY_ICON,
256 Attributes::File(attributes) => Self::get_file_icon(attributes),
257 Attributes::Symlink(_) => Self::DEFAULT_SYMLINK_ICON,
258 };
259 self.icons
261 .as_ref()
262 .map(|icons| {
263 icons
264 .get_icon(entry, default_choice)
265 .expect("Icon configuration should be valid")
266 .unwrap_or_else(|| String::from(Self::EMPTY_ICON))
267 })
268 .unwrap_or_else(|| String::from(default_choice))
269 }
270
271 fn get_file_icon(attributes: &FileAttributes) -> &'static str {
273 if attributes.is_executable() {
274 return Self::DEFAULT_EXECUTABLE_ICON;
275 }
276 attributes
277 .language()
278 .and_then(|language| language.nerd_font_glyph())
279 .unwrap_or(Self::DEFAULT_FILE_ICON)
280 }
281
282 fn write_colorized_for_entry<W, D, P2>(
284 &self,
285 entry: &Entry<P2>,
286 writer: &mut W,
287 display: D,
288 ) -> io::Result<()>
289 where
290 W: Write,
291 D: Display + OwoColorize,
292 P2: AsRef<Path>,
293 {
294 if self.color_choice.is_off() {
296 return write!(writer, "{display}");
297 }
298
299 let fg = match entry.attributes() {
300 Attributes::Directory(_) => Self::DEFAULT_DIRECTORY_COLOR,
301 Attributes::File(attributes) => Self::get_file_color(attributes),
302 Attributes::Symlink(_) => Self::DEFAULT_SYMLINK_COLOR,
303 };
304 let fg = self
305 .colors
306 .as_ref()
307 .and_then(|colors| {
308 colors
309 .for_icon(entry, fg)
310 .expect("Colors configuration should be valid")
311 })
312 .or(fg);
313
314 self.color_choice.write_to(writer, display, fg, None)
315 }
316
317 fn get_file_color(attributes: &FileAttributes) -> Option<Color> {
319 attributes
320 .language()
321 .map(|language| language.rgb())
322 .map(|(r, g, b)| Color::Rgb(r, g, b))
323 .or_else(|| {
324 attributes
325 .is_executable()
326 .then_some(Self::DEFAULT_EXECUTABLE_COLOR)
327 .flatten()
328 })
329 .or(Self::DEFAULT_FILE_COLOR)
330 }
331
332 fn write_statuses<W>(&self, writer: &mut W, path: &Path) -> io::Result<()>
334 where
335 W: Write,
336 {
337 let Some(git) = self.git else { return Ok(()) };
338
339 let path = self
341 .clean_path_for_git2(path)
342 .expect("Should be able to resolve path relative to git root");
343
344 self.write_status::<status::Untracked, _, _>(writer, git, &path)?;
345 self.write_status::<status::Tracked, _, _>(writer, git, path)?;
346 Ok(())
347 }
348
349 fn write_status<S, W, P2>(&self, writer: &mut W, git: &Git, path: P2) -> io::Result<()>
351 where
352 S: status::StatusGetter + StatusColor,
353 W: Write,
354 P2: AsRef<Path>,
355 {
356 const NO_STATUS: &str = " ";
357
358 let status = git.status::<S, _>(path).ok().flatten();
359 let color = status.and_then(|status| {
360 self.colors.as_ref().map_or_else(
361 || S::get_default_color(status),
362 |config| {
363 S::get_git_status_color(status, config)
364 .expect("Config should return a valid color")
365 },
366 )
367 });
368 let status = status.map(|status| status.as_str()).unwrap_or(NO_STATUS);
369 self.color_choice.write_to(writer, status, color, None)
370 }
371
372 fn clean_path_for_git2<P2>(&self, path: P2) -> Option<PathBuf>
374 where
375 P2: AsRef<Path>,
376 {
377 let git_root = self.git.and_then(|git| git.root_dir())?;
378
379 #[cfg(windows)]
383 let git_root = git_root
384 .canonicalize()
385 .expect("Git root should exist and non-final components should be directories");
386
387 let path = path.as_ref();
388 let path = path
389 .canonicalize()
390 .expect("Path should exist and non-final components should be directories");
391 let path = path
392 .strip_prefix(git_root)
393 .expect("Path should have the git root as a prefix");
394 Some(path.to_path_buf())
395 }
396}
397
398trait StatusColor {
400 const DEFAULT_ADDED: AnsiColors;
402 const DEFAULT_MODIFIED: AnsiColors;
404 const DEFAULT_REMOVED: AnsiColors;
406 const DEFAULT_RENAMED: AnsiColors;
408
409 fn get_default_color(status: Status) -> Option<Color> {
411 use Status::*;
412
413 let default_color = match status {
414 Added => Self::DEFAULT_ADDED,
415 Modified => Self::DEFAULT_MODIFIED,
416 Removed => Self::DEFAULT_REMOVED,
417 Renamed => Self::DEFAULT_RENAMED,
418 };
419
420 let default_color = Color::Ansi(default_color);
421 Some(default_color)
422 }
423
424 fn get_git_status_color(
426 status: Status,
427 color_config: &config::Colors,
428 ) -> mlua::Result<Option<Color>>;
429}
430
431impl StatusColor for status::Tracked {
432 const DEFAULT_ADDED: AnsiColors = AnsiColors::Green;
433 const DEFAULT_MODIFIED: AnsiColors = AnsiColors::Yellow;
434 const DEFAULT_REMOVED: AnsiColors = AnsiColors::Red;
435 const DEFAULT_RENAMED: AnsiColors = AnsiColors::Cyan;
436
437 fn get_git_status_color(
439 status: Status,
440 color_config: &config::Colors,
441 ) -> mlua::Result<Option<Color>> {
442 let default_choice = Self::get_default_color(status);
443 color_config.for_tracked_git_status(status, default_choice)
444 }
445}
446
447impl StatusColor for status::Untracked {
448 const DEFAULT_ADDED: AnsiColors = AnsiColors::BrightGreen;
449 const DEFAULT_MODIFIED: AnsiColors = AnsiColors::BrightYellow;
450 const DEFAULT_REMOVED: AnsiColors = AnsiColors::BrightRed;
451 const DEFAULT_RENAMED: AnsiColors = AnsiColors::BrightCyan;
452
453 fn get_git_status_color(
455 status: Status,
456 color_config: &config::Colors,
457 ) -> mlua::Result<Option<Color>> {
458 let default_choice = Self::get_default_color(status);
459 color_config.for_untracked_git_status(status, default_choice)
460 }
461}