use std::fmt::Debug;
use std::path::Path;
use nu_ansi_term::{AnsiString as ANSIString, Style};
use path_clean;
use unicode_width::UnicodeWidthStr;
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::output::render::FiletypeColours;
use crate::theme::FileNameStyle;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct Options {
pub classify: Classify,
pub show_icons: ShowIcons,
pub quote_style: QuoteStyle,
pub embed_hyperlinks: EmbedHyperlinks,
pub absolute: Absolute,
pub short_nix: bool,
pub is_a_tty: bool,
}
const SHORT_NIX_HASH_LENGTH: usize = 8;
const fn is_nix_base32(b: u8) -> bool {
matches!(b, b'0'..=b'9' | b'a'..=b'd' | b'f'..=b'n' | b'p'..=b's' | b'v'..=b'z')
}
fn split_nix_hash(component: &str) -> Option<(&str, &str)> {
let (hash, rest) = component.split_at_checked(32)?;
if rest.starts_with('-') && hash.bytes().all(is_nix_base32) {
Some((hash, rest))
} else {
None
}
}
#[derive(Debug, PartialEq, Eq)]
enum NameSegment {
Hash(String),
Text(String),
}
fn shorten_nix_segments(name: &str) -> Vec<NameSegment> {
let mut segments = Vec::new();
let mut text = String::new();
for (i, component) in name.split('/').enumerate() {
if i > 0 {
text.push('/');
}
if let Some((hash, rest)) = split_nix_hash(component) {
if !text.is_empty() {
segments.push(NameSegment::Text(std::mem::take(&mut text)));
}
segments.push(NameSegment::Hash(format!(
"{}…",
&hash[..SHORT_NIX_HASH_LENGTH]
)));
text.push_str(rest);
} else {
text.push_str(component);
}
}
if !text.is_empty() {
segments.push(NameSegment::Text(text));
}
segments
}
impl Options {
pub fn for_file<'a, 'dir, C>(
self,
file: &'a File<'dir>,
colours: &'a C,
) -> FileName<'a, 'dir, C> {
FileName {
file,
colours,
link_style: LinkStyle::JustFilenames,
options: self,
target: if file.is_link() {
Some(file.link_target())
} else {
None
},
mount_style: MountStyle::JustDirectoryNames,
}
}
}
#[derive(PartialEq, Debug, Copy, Clone)]
enum LinkStyle {
JustFilenames,
FullLinkPaths,
}
#[derive(PartialEq, Eq, Debug, Default, Copy, Clone)]
pub enum Classify {
#[default]
JustFilenames,
AddFileIndicators,
AutomaticAddFileIndicators,
}
#[derive(PartialEq, Debug, Copy, Clone)]
enum MountStyle {
JustDirectoryNames,
MountInfo,
}
#[derive(PartialEq, Debug, Copy, Clone)]
pub enum ShowIcons {
Always(u32),
Automatic(u32),
Never,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum EmbedHyperlinks {
Never,
Automatic,
Always,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum Absolute {
On,
Follow,
Off,
}
#[derive(PartialEq, Debug, Copy, Clone)]
pub enum QuoteStyle {
NoQuotes,
QuoteSpaces,
}
pub struct FileName<'a, 'dir, C> {
file: &'a File<'dir>,
colours: &'a C,
target: Option<FileTarget<'dir>>,
link_style: LinkStyle,
pub options: Options,
mount_style: MountStyle,
}
impl<C> FileName<'_, '_, C> {
#[must_use]
pub fn with_link_paths(mut self) -> Self {
if !self.file.deref_links {
self.link_style = LinkStyle::FullLinkPaths;
}
self
}
#[must_use]
pub fn with_mount_details(mut self, enable: bool) -> Self {
self.mount_style = if enable {
MountStyle::MountInfo
} else {
MountStyle::JustDirectoryNames
};
self
}
}
impl<C: Colours> FileName<'_, '_, C> {
#[must_use]
pub fn paint(&self) -> TextCellContents {
let mut bits = Vec::new();
let (icon_override, filename_style_override) = match self.colours.style_override(self.file)
{
Some(FileNameStyle { icon, filename }) => (icon, filename),
None => (None, None),
};
let spaces_count_opt = match self.options.show_icons {
ShowIcons::Always(spaces_count) => Some(spaces_count),
ShowIcons::Automatic(spaces_count) if self.options.is_a_tty => Some(spaces_count),
_ => None,
};
let should_add_classify_char = match self.options.classify {
Classify::AddFileIndicators => true,
Classify::AutomaticAddFileIndicators if self.options.is_a_tty => true,
_ => false,
};
if let Some(spaces_count) = spaces_count_opt {
let (style, icon) = match icon_override {
Some(icon_override) => (
if let Some(style_override) = icon_override.style {
style_override
} else {
iconify_style(self.style())
},
icon_override
.glyph
.unwrap_or_else(|| icon_for_file(self.file))
.to_string(),
),
None => (
iconify_style(self.style()),
icon_for_file(self.file).to_string(),
),
};
bits.push(style.paint(icon));
bits.push(style.paint(" ".repeat(spaces_count as usize)));
}
if self.file.parent_dir.is_none()
&& self.options.absolute == Absolute::Off
&& let Some(parent) = self.file.path.parent()
{
self.add_parent_bits(&mut bits, parent);
}
if !self.file.name.is_empty() {
for bit in self.escaped_file_name(filename_style_override) {
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.colours.normal_arrow().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,
quote_style: QuoteStyle::QuoteSpaces,
show_icons: ShowIcons::Never,
embed_hyperlinks: EmbedHyperlinks::Never,
is_a_tty: self.options.is_a_tty,
absolute: Absolute::Off,
short_nix: self.options.short_nix,
};
let target_name = FileName {
file: target,
colours: self.colours,
target: None,
link_style: LinkStyle::FullLinkPaths,
options: target_options,
mount_style: MountStyle::JustDirectoryNames,
};
for bit in target_name.escaped_file_name(filename_style_override) {
bits.push(bit);
}
if should_add_classify_char && 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.colours.broken_symlink().paint("->"));
bits.push(Style::default().paint(" "));
escape(
broken_path.display().to_string(),
&mut bits,
self.colours.broken_filename(),
self.colours.broken_control_char(),
self.options.quote_style,
);
}
FileTarget::Err(_) => {
}
}
} else if should_add_classify_char && let Some(class) = self.classify_char(self.file) {
bits.push(Style::default().paint(class));
}
if self.mount_style == MountStyle::MountInfo
&& let Some(mount_details) = self.file.mount_point_info()
{
bits.push(Style::default().paint(" ["));
bits.push(Style::default().paint(mount_details.source.clone()));
bits.push(Style::default().paint(" ("));
bits.push(Style::default().paint(mount_details.fstype.clone()));
bits.push(Style::default().paint(")]"));
}
bits.into()
}
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.colours
.symlink_path()
.paint(std::path::MAIN_SEPARATOR.to_string()),
);
} else if coconut >= 1 {
let mut parent_str = parent.to_string_lossy().to_string();
if self.options.short_nix {
parent_str = shorten_nix_segments(&parent_str)
.into_iter()
.map(|segment| match segment {
NameSegment::Hash(s) | NameSegment::Text(s) => s,
})
.collect();
}
escape(
parent_str,
bits,
self.colours.symlink_path(),
self.colours.control_char(),
self.options.quote_style,
);
bits.push(
self.colours
.symlink_path()
.paint(std::path::MAIN_SEPARATOR.to_string()),
);
}
}
#[cfg(unix)]
pub(crate) 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)]
pub(crate) fn classify_char(&self, file: &File<'_>) -> Option<&'static str> {
if file.is_directory() {
Some("/")
} else if file.is_link() {
Some("@")
} else {
None
}
}
fn escaped_file_name<'unused>(
&self,
style_override: Option<Style>,
) -> Vec<ANSIString<'unused>> {
let file_style = style_override.unwrap_or(self.style());
let mut bits = Vec::new();
let mut display_hyperlink = false;
let should_embed_hyperlinks = match self.options.embed_hyperlinks {
EmbedHyperlinks::Never => false,
EmbedHyperlinks::Automatic => self.options.is_a_tty,
EmbedHyperlinks::Always => true,
};
if should_embed_hyperlinks
&& let Some(abs_path) = self
.file
.absolute_path()
.and_then(|p| p.as_os_str().to_str())
{
bits.push(ANSIString::from(escape::get_hyperlink_start_tag(abs_path)));
display_hyperlink = true;
}
let display_name = self.display_name();
if self.options.short_nix {
for segment in shorten_nix_segments(&display_name) {
match segment {
NameSegment::Hash(hash) => {
bits.push(self.colours.nix_hash().paint(hash));
}
NameSegment::Text(text) => escape(
text,
&mut bits,
file_style,
self.colours.control_char(),
self.options.quote_style,
),
}
}
} else {
escape(
display_name,
&mut bits,
file_style,
self.colours.control_char(),
self.options.quote_style,
);
}
if display_hyperlink {
bits.push(ANSIString::from(escape::HYPERLINK_CLOSING));
}
bits
}
fn display_name(&self) -> String {
match self.options.absolute {
Absolute::On => std::env::current_dir().ok().and_then(|p| {
path_clean::clean(p.join(&self.file.path))
.to_str()
.map(std::borrow::ToOwned::to_owned)
}),
Absolute::Follow => self
.file
.absolute_path()
.and_then(|p| p.to_str())
.map(std::borrow::ToOwned::to_owned),
Absolute::Off => None,
}
.unwrap_or(self.file.name.clone())
}
#[must_use]
pub fn style(&self) -> Style {
if let LinkStyle::JustFilenames = self.link_style
&& let Some(ref target) = self.target
&& target.is_broken()
{
return self.colours.broken_symlink();
}
#[rustfmt::skip]
return match self.file {
f if f.is_mount_point() => self.colours.mount_point(),
f if f.is_directory() => self.colours.directory(),
#[cfg(unix)]
f if f.is_executable_file() => self.colours.executable_file(),
f if f.is_link() => self.colours.symlink(),
#[cfg(unix)]
f if f.is_pipe() => self.colours.pipe(),
#[cfg(unix)]
f if f.is_block_device() => self.colours.block_device(),
#[cfg(unix)]
f if f.is_char_device() => self.colours.char_device(),
#[cfg(unix)]
f if f.is_socket() => self.colours.socket(),
f if ! f.is_file() => self.colours.special(),
_ => self.colours.colour_file(self.file),
};
}
#[must_use]
pub fn bare_utf8_width(&self) -> usize {
if self.options.short_nix {
let shortened: String = shorten_nix_segments(&self.file.name)
.into_iter()
.map(|segment| match segment {
NameSegment::Hash(s) | NameSegment::Text(s) => s,
})
.collect();
UnicodeWidthStr::width(shortened.as_str())
} else {
UnicodeWidthStr::width(self.file.name.as_str())
}
}
}
pub trait Colours: FiletypeColours {
fn symlink_path(&self) -> Style;
fn normal_arrow(&self) -> Style;
fn broken_symlink(&self) -> Style;
fn broken_filename(&self) -> Style;
fn control_char(&self) -> Style;
fn nix_hash(&self) -> Style;
fn broken_control_char(&self) -> Style;
fn executable_file(&self) -> Style;
fn mount_point(&self) -> Style;
fn colour_file(&self, file: &File<'_>) -> Style;
fn style_override(&self, file: &File<'_>) -> Option<FileNameStyle>;
}
#[cfg(test)]
mod test {
use super::*;
const HASH: &str = "vlkia5wk0svsikwv50554mh06iayg2m2";
#[test]
fn splits_a_store_file_name() {
let name = format!("{HASH}-source.drv");
assert_eq!(split_nix_hash(&name), Some((HASH, "-source.drv")));
}
#[test]
fn rejects_wrong_length_and_alphabet() {
assert_eq!(split_nix_hash("abc-source.drv"), None);
assert_eq!(split_nix_hash(HASH), None);
assert_eq!(
split_nix_hash("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-source.drv"),
None
);
}
#[test]
fn plain_names_pass_through_untouched() {
assert_eq!(
shorten_nix_segments("Cargo.toml"),
vec![NameSegment::Text("Cargo.toml".into())]
);
}
#[test]
fn shortens_a_bare_store_name() {
assert_eq!(
shorten_nix_segments(&format!("{HASH}-source.drv")),
vec![
NameSegment::Hash("vlkia5wk…".into()),
NameSegment::Text("-source.drv".into()),
]
);
}
#[test]
fn shortens_every_component_of_a_path() {
let path = format!("/nix/store/{HASH}-foo/bin/{HASH}-bar");
assert_eq!(
shorten_nix_segments(&path),
vec![
NameSegment::Text("/nix/store/".into()),
NameSegment::Hash("vlkia5wk…".into()),
NameSegment::Text("-foo/bin/".into()),
NameSegment::Hash("vlkia5wk…".into()),
NameSegment::Text("-bar".into()),
]
);
}
#[test]
fn hash_mid_component_is_not_shortened() {
let name = format!("prefix-{HASH}-source.drv");
assert_eq!(
shorten_nix_segments(&name),
vec![NameSegment::Text(name.clone())]
);
}
#[test]
fn multibyte_names_are_split_safely() {
assert_eq!(split_nix_hash("ααααααααααααααααα-x"), None);
}
}