fancy_tree/tree/
mod.rs

1//! Provides the utility for generating a tree.
2use 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
20/// Generates a tree.
21pub struct Tree<'git, 'charset, P: AsRef<Path>> {
22    /// The root path to start from.
23    root: P,
24    /// The optional git state of the directory.
25    git: Option<&'git Git>,
26    /// The maximum depth level to display.
27    max_level: Option<usize>,
28    /// Provides the characters to print when traversing the directory structure.
29    charset: Charset<'charset>,
30    /// Controls how the tree colorizes output.
31    color_choice: ColorChoice,
32    /// Provides configuration choices.
33    ///
34    /// When this is `None`, default behaviors will be used.
35    config: Option<config::Main>,
36    /// Provides icon configuration.
37    ///
38    /// When this is `None`, default behaviors will be used.
39    icons: Option<config::Icons>,
40    /// Provides color configuration.
41    ///
42    /// When this is `None`, default behaviors will be used.
43    colors: Option<config::Colors>,
44}
45
46impl<'git, 'charset, P> Tree<'git, 'charset, P>
47where
48    P: AsRef<Path>,
49{
50    /// The default icon to display for files.
51    const DEFAULT_FILE_ICON: &'static str = "\u{f0214}"; // 󰈔
52    /// The default icon to display when a file is an executable.
53    const DEFAULT_EXECUTABLE_ICON: &'static str = "\u{f070e}"; // 󰜎
54    /// The default icon to display for directories/folders.
55    const DEFAULT_DIRECTORY_ICON: &'static str = "\u{f024b}"; // 󰉋
56    /// The default icon to display for symlinks.
57    const DEFAULT_SYMLINK_ICON: &'static str = "\u{cf481}"; // 
58
59    /// The icon (padding) to use if there is no icon.
60    const EMPTY_ICON: &'static str = " ";
61
62    /// The default color to use for files.
63    const DEFAULT_FILE_COLOR: Option<Color> = None;
64    /// The default color to use when a file is an executable.
65    const DEFAULT_EXECUTABLE_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Green));
66    /// The default color to use for directories/folders.
67    const DEFAULT_DIRECTORY_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Blue));
68    /// The default color to use for symlinks.
69    const DEFAULT_SYMLINK_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Cyan));
70
71    /// Writes the tree to stdout.
72    #[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    /// Writes to the writer.
83    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            // HACK We can't read the first entry for some reason, so we'll just print
89            //      it and exit.
90            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    /// Writes the tree at a certain depth to the writer.
99    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        // NOTE For the top level, we always print the full path the user specified.
107        self.write_entry(writer, &entry, depth == 0)?;
108
109        writeln!(writer)?;
110        if !path.is_dir() {
111            return Ok(());
112        }
113
114        // NOTE We'll just skip file read errors to continue printing the rest of the
115        //      tree.
116        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            // NOTE If we can't read a directory entry, then we'll just ignore it so that
123            //      we don't stop early.
124            let entries = entries.filter_map(Result::ok);
125
126            // NOTE If the config exists and it successfully detects if a file should
127            //      be skipped, use that value. Otherwise, use default behavior.
128            let entries = entries.filter(|entry| !self.should_skip_entry(entry));
129
130            // NOTE By default entry order is not guaranteed. This explicitly sorts them.
131            // TODO Support different sorting algorithms.
132            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    /// Writes an entry.
153    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        // NOTE Padding for the icons
164        write!(writer, " ")?;
165
166        // HACK is_path_ignored tries to strip the prefix, which we never want to do at
167        //      the top when the path is *only* the prefix. In fact, we don't want to
168        //      check ignore status here at all since the current implementation breaks
169        //      for paths that contain the directory `.`, it seems. Also, the top
170        //      should always be a directory, and the current implementation only seems
171        //      to work for files.
172        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            // NOTE The only time the path shouldn't have a file name is at the top
178            //      level, which could be a path like "." or "..". At the top level
179            //      call, `full_name` should always receive `true`.
180            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    /// Writes a path's name.
194    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    /// Writes indentation.
204    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    /// Checks if an entry should be skipped.
215    ///
216    /// If the config exists, the config has a `skip` function, *and* that function
217    /// successfully returns a boolean value, then that value will be used. Otherwise,
218    /// it will just skip all hidden files.
219    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    /// Checks if a path is ignored.
233    fn is_path_ignored<P2>(&self, path: P2) -> bool
234    where
235        P2: AsRef<Path>,
236    {
237        self.git
238            .and_then(|git| {
239                // HACK This function doesn't expect a `./` prefix. It seems to return
240                //      `true` when it's present???
241                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    /// Gets the icon for an entry.
250    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        // TODO Don't panic on get_icon error.
260        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    /// Gets the icon for a file entry.
272    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    /// Writes the text in a colored style.
283    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        // HACK Optimization to avoid calculating colors when they're disabled.
295        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    /// Gets the color for a file.
318    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    /// Writes colorized git statuses.
333    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        // HACK cached status keys don't have a ./ prefix and git2 apparently doesn't expect it.
340        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    /// Writes a colorized git status.
350    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    /// Strips the root path prefix, which is necessary for git tools.
373    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        // HACK Git root seems to have `/` separators, which breaks path cleanup on
380        //      Windows. This cleans up the git root so it can be used with
381        //      strip_prefix.
382        #[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
398/// Private trait to generalize getting the color for a status.
399trait StatusColor {
400    /// Default color for added status.
401    const DEFAULT_ADDED: AnsiColors;
402    /// Default color for modified status.
403    const DEFAULT_MODIFIED: AnsiColors;
404    /// Default color for removed status.
405    const DEFAULT_REMOVED: AnsiColors;
406    /// Default color for renamed status.
407    const DEFAULT_RENAMED: AnsiColors;
408
409    /// Gets the default color for a status.
410    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    /// Gets the color for a git status.
425    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    /// Gets the tracked git status color.
438    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    /// Gets the untracked git status color.
454    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}