lx-ls 0.10.1

The file lister with personality! 🌟
use std::fmt::Debug;
use std::path::Path;

use nu_ansi_term::{AnsiString, Style};

use crate::fs::{File, FileTarget};
use crate::output::cell::TextCellContents;
use crate::output::escape;
use crate::output::icons::{icon_for_file, iconify_style};
use crate::theme::{Theme, apply_overlay};

/// Basically a file name factory.
#[derive(Debug, Copy, Clone)]
pub struct Options {
    /// Whether to append file class characters to file names.
    pub classify: Classify,

    /// Whether to prepend icon characters before file names.
    pub show_icons: ShowIcons,

    /// Whether to display absolute file paths.
    pub absolute: bool,

    /// Whether to wrap file names in OSC 8 hyperlinks.
    pub hyperlink: bool,

    /// Whether to quote file names containing spaces.
    pub quotes: Quotes,
}

/// Whether to quote filenames that contain spaces or special characters.
#[derive(PartialEq, Eq, Debug, Copy, Clone, Default)]
pub enum Quotes {
    /// Always quote.
    Always,
    /// Quote only names with spaces.
    #[default]
    Never,
}

impl Options {
    /// Create a new `FileName` that prints the given file’s name, painting it
    /// with the remaining arguments.
    pub fn for_file<'a, 'dir>(self, file: &'a File<'dir>, theme: &'a Theme) -> FileName<'a, 'dir> {
        FileName {
            file,
            theme,
            link_style: LinkStyle::JustFilenames,
            options: self,
            target: if file.is_link() {
                Some(file.link_target())
            } else {
                None
            },
        }
    }
}

/// When displaying a file name, there needs to be some way to handle broken
/// links, depending on how long the resulting Cell can be.
#[derive(PartialEq, Debug, Copy, Clone)]
enum LinkStyle {
    /// Just display the file names, but colour them differently if they’re
    /// a broken link or can’t be followed.
    JustFilenames,

    /// Display all files in their usual style, but follow each link with an
    /// arrow pointing to their path, colouring the path differently if it’s
    /// a broken link, and doing nothing if it can’t be followed.
    FullLinkPaths,
}

/// Whether to append file class characters to the file names.
#[derive(PartialEq, Eq, Debug, Copy, Clone, Default)]
pub enum Classify {
    /// Just display the file names, without any characters.
    #[default]
    JustFilenames,

    /// Add a character after the file name depending on what class of file
    /// it is.
    AddFileIndicators,
}

/// Whether and how to show icons.
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum ShowIcons {
    /// Don’t show icons at all.
    Off,

    /// Show icons next to file names, with the given number of spaces between
    /// the icon and the file name.
    On(u32),
}

/// A **file name** holds all the information necessary to display the name
/// of the given file. This is used in all of the views.
pub struct FileName<'a, 'dir> {
    /// A reference to the file that we’re getting the name of.
    file: &'a File<'dir>,

    /// The theme used to paint the file name and its surrounding text.
    theme: &'a Theme,

    /// The file that this file points to if it’s a link.
    target: Option<FileTarget<'dir>>, // todo: remove?

    /// How to handle displaying links.
    link_style: LinkStyle,

    options: Options,
}

impl FileName<'_, '_> {
    /// Sets the flag on this file name to display link targets with an
    /// arrow followed by their path.
    pub fn with_link_paths(mut self) -> Self {
        self.link_style = LinkStyle::FullLinkPaths;
        self
    }
    /// Paints the name of the file using the colours, resulting in a vector
    /// of coloured cells that can be printed to the terminal.
    ///
    /// This method returns some `TextCellContents`, rather than a `TextCell`,
    /// because for the last cell in a table, it doesn’t need to have its
    /// width calculated.
    pub fn paint(&self) -> TextCellContents {
        let mut bits = Vec::new();

        if let ShowIcons::On(spaces_count) = self.options.show_icons {
            let style = iconify_style(self.style());
            let file_icon = icon_for_file(self.file).to_string();

            bits.push(style.paint(file_icon));

            match spaces_count {
                1 => bits.push(style.paint(" ")),
                2 => bits.push(style.paint("  ")),
                n => bits.push(style.paint(spaces(n))),
            }
        }

        // OSC 8 hyperlink opening.
        if self.options.hyperlink
            && let Ok(abs) = std::fs::canonicalize(&self.file.path)
        {
            let uri = format!("file://{}", abs.display());
            bits.push(Style::default().paint(format!("\x1b]8;;{uri}\x07")));
        }

        // Opening quote.
        let needs_quotes = self.options.quotes == Quotes::Always && self.file.name.contains(' ');
        if needs_quotes {
            bits.push(Style::default().paint("\""));
        }

        // Absolute path: show the full resolved parent path before
        // the file's name.  Canonicalise the directory rather than
        // the full file path, so that `.` and `..` entries keep
        // their names instead of being resolved away.  The
        // synthetic dot entries are special: `.` has the listed
        // directory's path with no suffix, while `..` has it joined
        // with `..`, so neither yields the right prefix via
        // `Path::parent` alone.
        if self.options.absolute {
            let parent: Option<&Path> = if self.file.is_all_all {
                self.file.parent_dir.map(|d| d.path.as_path())
            } else {
                self.file.path.parent()
            };
            let canonical = match parent {
                Some(p) if !p.as_os_str().is_empty() => std::fs::canonicalize(p).ok(),
                _ => std::env::current_dir().ok(),
            };
            if let Some(abs) = canonical {
                self.add_parent_bits(&mut bits, &abs);
            }
        } else if self.file.parent_dir.is_none()
            && let Some(parent) = self.file.path.parent()
        {
            self.add_parent_bits(&mut bits, parent);
        }

        if !self.file.name.is_empty() {
            // The “missing file” colour seems like it should be used here,
            // but it’s not! In a grid view, where there’s no space to display
            // link targets, the filename has to have a different style to
            // indicate this fact. But when showing targets, we can just
            // colour the path instead (see below), and leave the broken
            // link’s filename as the link colour.
            for bit in self.coloured_file_name() {
                bits.push(bit);
            }
        }

        if let (LinkStyle::FullLinkPaths, Some(target)) = (self.link_style, self.target.as_ref()) {
            match target {
                FileTarget::Ok(target) => {
                    bits.push(Style::default().paint(" "));
                    bits.push(self.theme.ui.punctuation.paint("->"));
                    bits.push(Style::default().paint(" "));

                    if let Some(parent) = target.path.parent() {
                        self.add_parent_bits(&mut bits, parent);
                    }

                    if !target.name.is_empty() {
                        let target_options = Options {
                            classify: Classify::JustFilenames,
                            show_icons: ShowIcons::Off,
                            absolute: false,
                            hyperlink: false,
                            quotes: Quotes::Never,
                        };

                        let target_name = FileName {
                            file: target,
                            theme: self.theme,
                            target: None,
                            link_style: LinkStyle::FullLinkPaths,
                            options: target_options,
                        };

                        for bit in target_name.coloured_file_name() {
                            bits.push(bit);
                        }

                        if let Classify::AddFileIndicators = self.options.classify
                            && let Some(class) = self.classify_char(target)
                        {
                            bits.push(Style::default().paint(class));
                        }
                    }
                }

                FileTarget::Broken(broken_path) => {
                    bits.push(Style::default().paint(" "));
                    bits.push(self.theme.ui.broken_symlink.paint("->"));
                    bits.push(Style::default().paint(" "));

                    escape(
                        broken_path.display().to_string(),
                        &mut bits,
                        apply_overlay(
                            self.theme.ui.broken_symlink,
                            self.theme.ui.broken_path_overlay,
                        ),
                        apply_overlay(
                            self.theme.ui.control_char,
                            self.theme.ui.broken_path_overlay,
                        ),
                    );
                }

                FileTarget::Err(_) => {
                    // Do nothing — the error gets displayed on the next line
                }
            }
        } else if let Classify::AddFileIndicators = self.options.classify
            && let Some(class) = self.classify_char(self.file)
        {
            bits.push(Style::default().paint(class));
        }

        // Closing quote.
        if needs_quotes {
            bits.push(Style::default().paint("\""));
        }

        // OSC 8 hyperlink closing.
        if self.options.hyperlink {
            bits.push(Style::default().paint("\x1b]8;;\x07"));
        }

        bits.into()
    }

    /// Adds the bits of the parent path to the given bits vector.
    /// The path gets its characters escaped based on the colours.
    fn add_parent_bits(&self, bits: &mut Vec<AnsiString<'_>>, parent: &Path) {
        let coconut = parent.components().count();

        if coconut == 1 && parent.has_root() {
            bits.push(
                self.theme
                    .ui
                    .symlink_path
                    .paint(std::path::MAIN_SEPARATOR.to_string()),
            );
        } else if coconut >= 1 {
            escape(
                parent.to_string_lossy().to_string(),
                bits,
                self.theme.ui.symlink_path,
                self.theme.ui.control_char,
            );
            bits.push(
                self.theme
                    .ui
                    .symlink_path
                    .paint(std::path::MAIN_SEPARATOR.to_string()),
            );
        }
    }

    /// The character to be displayed after a file when classifying is on, if
    /// the file’s type has one associated with it.
    #[cfg(unix)]
    fn classify_char(&self, file: &File<'_>) -> Option<&'static str> {
        if file.is_executable_file() {
            Some("*")
        } else if file.is_directory() {
            Some("/")
        } else if file.is_pipe() {
            Some("|")
        } else if file.is_link() {
            Some("@")
        } else if file.is_socket() {
            Some("=")
        } else {
            None
        }
    }

    #[cfg(windows)]
    fn classify_char(&self, file: &File<'_>) -> Option<&'static str> {
        if file.is_directory() {
            Some("/")
        } else if file.is_link() {
            Some("@")
        } else {
            None
        }
    }

    /// Returns at least one ANSI-highlighted string representing this file’s
    /// name using the given set of colours.
    ///
    /// Ordinarily, this will be just one string: the file’s complete name,
    /// coloured according to its file type. If the name contains control
    /// characters such as newlines or escapes, though, we can’t just print them
    /// to the screen directly, because then there’ll be newlines in weird places.
    ///
    /// So in that situation, those characters will be escaped and highlighted in
    /// a different colour.
    fn coloured_file_name<'unused>(&self) -> Vec<AnsiString<'unused>> {
        let file_style = self.style();
        let mut bits = Vec::new();

        escape(
            self.file.name.clone(),
            &mut bits,
            file_style,
            self.theme.ui.control_char,
        );

        bits
    }

    /// Figures out which colour to paint the filename part of the output,
    /// depending on which “type” of file it appears to be — either from the
    /// class on the filesystem or from its name. (Or the broken link colour,
    /// if there’s nowhere else for that fact to be shown.)
    pub fn style(&self) -> Style {
        if let LinkStyle::JustFilenames = self.link_style
            && let Some(ref target) = self.target
            && target.is_broken()
        {
            return self.theme.ui.broken_symlink;
        }

        let kinds = &self.theme.ui.filekinds;
        match self.file {
            f if f.is_directory() => kinds.directory,
            #[cfg(unix)]
            f if f.is_executable_file() => kinds.executable,
            f if f.is_link() => kinds.symlink,
            #[cfg(unix)]
            f if f.is_pipe() => kinds.pipe,
            #[cfg(unix)]
            f if f.is_block_device() => kinds.block_device,
            #[cfg(unix)]
            f if f.is_char_device() => kinds.char_device,
            #[cfg(unix)]
            f if f.is_socket() => kinds.socket,
            f if !f.is_file() => kinds.special,
            _ => self.theme.colour_file(self.file),
        }
    }
}

/// Generate a string made of `n` spaces.
fn spaces(width: u32) -> String {
    (0..width).map(|_| ' ').collect()
}