mod fs;
pub mod style;
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::fs::{DirEntry, FileType, Metadata};
use std::path::{Component, Path, PathBuf, MAIN_SEPARATOR};
pub use crate::style::{Color, FontStyle, Style};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Indicator {
Normal,
RegularFile,
Directory,
SymbolicLink,
FIFO,
Socket,
Door,
BlockDevice,
CharacterDevice,
OrphanedSymbolicLink,
Setuid,
Setgid,
Sticky,
OtherWritable,
StickyAndOtherWritable,
ExecutableFile,
MissingFile,
Capabilities,
MultipleHardLinks,
LeftCode,
RightCode,
EndCode,
Reset,
ClearLine,
}
impl Indicator {
pub fn from(indicator: &str) -> Option<Indicator> {
match indicator {
"no" => Some(Indicator::Normal),
"fi" => Some(Indicator::RegularFile),
"di" => Some(Indicator::Directory),
"ln" => Some(Indicator::SymbolicLink),
"pi" => Some(Indicator::FIFO),
"so" => Some(Indicator::Socket),
"do" => Some(Indicator::Door),
"bd" => Some(Indicator::BlockDevice),
"cd" => Some(Indicator::CharacterDevice),
"or" => Some(Indicator::OrphanedSymbolicLink),
"su" => Some(Indicator::Setuid),
"sg" => Some(Indicator::Setgid),
"st" => Some(Indicator::Sticky),
"ow" => Some(Indicator::OtherWritable),
"tw" => Some(Indicator::StickyAndOtherWritable),
"ex" => Some(Indicator::ExecutableFile),
"mi" => Some(Indicator::MissingFile),
"ca" => Some(Indicator::Capabilities),
"mh" => Some(Indicator::MultipleHardLinks),
"lc" => Some(Indicator::LeftCode),
"rc" => Some(Indicator::RightCode),
"ec" => Some(Indicator::EndCode),
"rs" => Some(Indicator::Reset),
"cl" => Some(Indicator::ClearLine),
_ => None,
}
}
}
type FileNameSuffix = String;
pub struct StyledComponents<'a> {
lscolors: &'a LsColors,
component_path: PathBuf,
components: std::iter::Peekable<std::path::Components<'a>>,
}
impl<'a> Iterator for StyledComponents<'a> {
type Item = (OsString, Option<&'a Style>);
fn next(&mut self) -> Option<Self::Item> {
if let Some(component) = self.components.next() {
let mut component_str = component.as_os_str().to_os_string();
self.component_path.push(&component_str);
let style = self.lscolors.style_for_path(&self.component_path);
if self.components.peek().is_some() {
match component {
Component::Prefix(_) | Component::RootDir => {}
Component::CurDir | Component::ParentDir | Component::Normal(_) => {
component_str.push(MAIN_SEPARATOR.to_string());
}
}
}
Some((component_str, style))
} else {
None
}
}
}
pub trait Colorable {
fn path(&self) -> PathBuf;
fn file_name(&self) -> OsString;
fn file_type(&self) -> Option<FileType>;
fn metadata(&self) -> Option<Metadata>;
}
impl Colorable for DirEntry {
fn path(&self) -> PathBuf {
self.path()
}
fn file_name(&self) -> OsString {
self.file_name()
}
fn file_type(&self) -> Option<FileType> {
self.file_type().ok()
}
fn metadata(&self) -> Option<Metadata> {
self.metadata().ok()
}
}
const LS_COLORS_DEFAULT: &str = "rs=0:lc=\x1b[:rc=m:cl=\x1b[K:ex=01;32:sg=30;43:su=37;41:di=01;34:st=37;44:ow=34;42:tw=30;42:ln=01;36:bd=01;33:cd=01;33:do=01;35:pi=33:so=01;35:";
#[derive(Debug, Clone)]
pub struct LsColors {
indicator_mapping: HashMap<Indicator, Style>,
suffix_mapping: Vec<(FileNameSuffix, Option<Style>)>,
}
impl Default for LsColors {
fn default() -> Self {
let mut lscolors = LsColors::empty();
lscolors.add_from_string(LS_COLORS_DEFAULT);
lscolors
}
}
impl LsColors {
pub fn empty() -> Self {
LsColors {
indicator_mapping: HashMap::new(),
suffix_mapping: vec![],
}
}
pub fn from_env() -> Option<Self> {
env::var("LS_COLORS")
.ok()
.as_ref()
.map(|s| Self::from_string(s))
}
pub fn from_string(input: &str) -> Self {
let mut lscolors = LsColors::default();
lscolors.add_from_string(input);
lscolors
}
fn add_from_string(&mut self, input: &str) {
for entry in input.split(':') {
let parts: Vec<_> = entry.split('=').collect();
if let Some([entry, ansi_style]) = parts.get(0..2) {
let style = Style::from_ansi_sequence(ansi_style);
if let Some(suffix) = entry.strip_prefix('*') {
self.suffix_mapping
.push((suffix.to_string().to_ascii_lowercase(), style));
} else if let Some(indicator) = Indicator::from(entry) {
if let Some(style) = style {
self.indicator_mapping.insert(indicator, style);
} else {
self.indicator_mapping.remove(&indicator);
}
}
}
}
}
pub fn style_for_path<P: AsRef<Path>>(&self, path: P) -> Option<&Style> {
let metadata = path.as_ref().symlink_metadata().ok();
self.style_for_path_with_metadata(path, metadata.as_ref())
}
fn has_color_for(&self, indicator: Indicator) -> bool {
self.indicator_mapping.contains_key(&indicator)
}
fn needs_file_metadata(&self) -> bool {
self.has_color_for(Indicator::Setuid)
|| self.has_color_for(Indicator::Setgid)
|| self.has_color_for(Indicator::ExecutableFile)
|| self.has_color_for(Indicator::MultipleHardLinks)
}
fn needs_dir_metadata(&self) -> bool {
self.has_color_for(Indicator::StickyAndOtherWritable)
|| self.has_color_for(Indicator::OtherWritable)
|| self.has_color_for(Indicator::Sticky)
}
fn indicator_for<F: Colorable>(&self, file: &F) -> Indicator {
let file_type = file.file_type();
if let Some(file_type) = file_type {
if file_type.is_file() {
if self.needs_file_metadata() {
if let Some(metadata) = file.metadata() {
let mode = crate::fs::mode(&metadata);
let nlink = crate::fs::nlink(&metadata);
if self.has_color_for(Indicator::Setuid) && mode & 0o4000 != 0 {
return Indicator::Setuid;
} else if self.has_color_for(Indicator::Setgid) && mode & 0o2000 != 0 {
return Indicator::Setgid;
} else if self.has_color_for(Indicator::ExecutableFile)
&& mode & 0o0111 != 0
{
return Indicator::ExecutableFile;
} else if self.has_color_for(Indicator::MultipleHardLinks) && nlink > 1 {
return Indicator::MultipleHardLinks;
}
}
}
Indicator::RegularFile
} else if file_type.is_dir() {
if self.needs_dir_metadata() {
if let Some(metadata) = file.metadata() {
let mode = crate::fs::mode(&metadata);
if self.has_color_for(Indicator::StickyAndOtherWritable)
&& mode & 0o1002 == 0o1002
{
return Indicator::StickyAndOtherWritable;
} else if self.has_color_for(Indicator::OtherWritable) && mode & 0o0002 != 0
{
return Indicator::OtherWritable;
} else if self.has_color_for(Indicator::Sticky) && mode & 0o1000 != 0 {
return Indicator::Sticky;
}
}
}
Indicator::Directory
} else if file_type.is_symlink() {
if self.has_color_for(Indicator::OrphanedSymbolicLink) && !file.path().exists() {
return Indicator::OrphanedSymbolicLink;
}
Indicator::SymbolicLink
} else {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if file_type.is_fifo() {
return Indicator::FIFO;
}
if file_type.is_socket() {
return Indicator::Socket;
}
if file_type.is_block_device() {
return Indicator::BlockDevice;
}
if file_type.is_char_device() {
return Indicator::CharacterDevice;
}
}
Indicator::MissingFile
}
} else {
Indicator::RegularFile
}
}
pub fn style_for<F: Colorable>(&self, file: &F) -> Option<&Style> {
let indicator = self.indicator_for(file);
if indicator == Indicator::RegularFile {
let filename = file.file_name();
return self.style_for_str(filename.to_str()?);
}
self.style_for_indicator(indicator)
}
pub fn style_for_str(&self, file_str: &str) -> Option<&Style> {
let input = file_str.to_ascii_lowercase();
let input_ref = input.as_str();
for (suffix, style) in self.suffix_mapping.iter().rev() {
if input_ref.ends_with(suffix.as_str()) {
return style.as_ref();
}
}
None
}
pub fn style_for_path_with_metadata<P: AsRef<Path>>(
&self,
path: P,
metadata: Option<&std::fs::Metadata>,
) -> Option<&Style> {
struct PathWithMetadata<'a> {
path: &'a Path,
metadata: Option<&'a Metadata>,
}
impl Colorable for PathWithMetadata<'_> {
fn path(&self) -> PathBuf {
self.path.to_owned()
}
fn file_name(&self) -> OsString {
self.path
.components()
.last()
.map(|c| c.as_os_str())
.unwrap_or_else(|| self.path.as_os_str())
.to_owned()
}
fn file_type(&self) -> Option<FileType> {
self.metadata.map(|m| m.file_type())
}
fn metadata(&self) -> Option<Metadata> {
self.metadata.cloned()
}
}
let path = path.as_ref();
self.style_for(&PathWithMetadata { path, metadata })
}
pub fn style_for_path_components<'a>(&'a self, path: &'a Path) -> StyledComponents<'a> {
StyledComponents {
lscolors: self,
component_path: PathBuf::new(),
components: path.components().peekable(),
}
}
pub fn style_for_indicator(&self, indicator: Indicator) -> Option<&Style> {
self.indicator_mapping
.get(&indicator)
.or_else(|| {
self.indicator_mapping.get(&match indicator {
Indicator::Setuid
| Indicator::Setgid
| Indicator::ExecutableFile
| Indicator::MultipleHardLinks => Indicator::RegularFile,
Indicator::StickyAndOtherWritable
| Indicator::OtherWritable
| Indicator::Sticky => Indicator::Directory,
Indicator::OrphanedSymbolicLink => Indicator::SymbolicLink,
Indicator::MissingFile => Indicator::OrphanedSymbolicLink,
_ => indicator,
})
})
.or_else(|| self.indicator_mapping.get(&Indicator::Normal))
}
}
#[cfg(test)]
mod tests {
use crate::style::{Color, FontStyle, Style};
use crate::{Indicator, LsColors};
use std::fs::{self, File};
use std::path::{Path, PathBuf};
#[test]
fn basic_usage() {
let lscolors = LsColors::from_string("*.wav=00;36:");
let style_dir = lscolors.style_for_indicator(Indicator::Directory).unwrap();
assert_eq!(FontStyle::bold(), style_dir.font_style);
assert_eq!(Some(Color::Blue), style_dir.foreground);
assert_eq!(None, style_dir.background);
let style_symlink = lscolors
.style_for_indicator(Indicator::SymbolicLink)
.unwrap();
assert_eq!(FontStyle::bold(), style_symlink.font_style);
assert_eq!(Some(Color::Cyan), style_symlink.foreground);
assert_eq!(None, style_symlink.background);
let style_rs = lscolors.style_for_path("test.wav").unwrap();
assert_eq!(FontStyle::default(), style_rs.font_style);
assert_eq!(Some(Color::Cyan), style_rs.foreground);
assert_eq!(None, style_rs.background);
}
#[test]
fn style_for_path_uses_correct_ordering() {
let lscolors = LsColors::from_string("*.foo=01;35:*README.foo=33;44");
let style_foo = lscolors.style_for_path("some/folder/dummy.foo").unwrap();
assert_eq!(FontStyle::bold(), style_foo.font_style);
assert_eq!(Some(Color::Magenta), style_foo.foreground);
assert_eq!(None, style_foo.background);
let style_readme = lscolors
.style_for_path("some/other/folder/README.foo")
.unwrap();
assert_eq!(FontStyle::default(), style_readme.font_style);
assert_eq!(Some(Color::Yellow), style_readme.foreground);
assert_eq!(Some(Color::Blue), style_readme.background);
}
#[test]
fn style_for_path_uses_lowercase_matching() {
let lscolors = LsColors::from_string("*.O=01;35");
let style_artifact = lscolors.style_for_path("artifact.o").unwrap();
assert_eq!(FontStyle::bold(), style_artifact.font_style);
assert_eq!(Some(Color::Magenta), style_artifact.foreground);
assert_eq!(None, style_artifact.background);
}
#[test]
fn default_styles_should_be_preserved() {
let lscolors = LsColors::from_string("ex=01:");
let style_dir = lscolors.style_for_indicator(Indicator::Directory).unwrap();
assert_eq!(FontStyle::bold(), style_dir.font_style);
assert_eq!(Some(Color::Blue), style_dir.foreground);
assert_eq!(None, style_dir.background);
}
fn temp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("temporary directory")
}
fn create_file<P: AsRef<Path>>(path: P) -> PathBuf {
File::create(&path).expect("temporary file");
path.as_ref().to_path_buf()
}
fn create_dir<P: AsRef<Path>>(path: P) -> PathBuf {
fs::create_dir(&path).expect("temporary directory");
path.as_ref().to_path_buf()
}
fn get_default_style<P: AsRef<Path>>(path: P) -> Option<Style> {
let lscolors = LsColors::default();
lscolors.style_for_path(path).cloned()
}
#[cfg(unix)]
fn create_symlink<P: AsRef<Path>>(from: P, to: P) {
std::os::unix::fs::symlink(from, to).expect("temporary symlink");
}
#[cfg(windows)]
fn create_symlink<P: AsRef<Path>>(src: P, dst: P) {
if src.as_ref().is_dir() {
std::os::windows::fs::symlink_dir(src, dst).expect("temporary symlink");
} else {
std::os::windows::fs::symlink_file(src, dst).expect("temporary symlink");
}
}
#[test]
fn style_for_str() {
let lscolors = LsColors::from_string("*.wav=00;36:*.rs=1;38;5;202:");
assert_eq!(lscolors.style_for_str(""), None);
assert_eq!(lscolors.style_for_str("test"), None);
assert_eq!(
lscolors.style_for_str("test.wav").unwrap().foreground,
Some(Color::Cyan)
);
assert_eq!(
lscolors.style_for_str("test.rs").unwrap().foreground,
Some(Color::Fixed(202))
);
}
#[test]
fn style_for_directory() {
let tmp_dir = temp_dir();
let style = get_default_style(tmp_dir.path()).unwrap();
assert_eq!(Some(Color::Blue), style.foreground);
}
#[test]
fn style_for_file() {
let tmp_dir = temp_dir();
let tmp_file_path = create_file(tmp_dir.path().join("test-file"));
let style = get_default_style(tmp_file_path);
assert_eq!(None, style);
}
#[test]
fn style_for_symlink() {
let tmp_dir = temp_dir();
let tmp_file_path = create_file(tmp_dir.path().join("test-file"));
let tmp_symlink_path = tmp_dir.path().join("test-symlink");
create_symlink(&tmp_file_path, &tmp_symlink_path);
let style = get_default_style(tmp_symlink_path).unwrap();
assert_eq!(Some(Color::Cyan), style.foreground);
}
#[test]
fn style_for_broken_symlink() {
let tmp_dir = temp_dir();
let tmp_file_path = tmp_dir.path().join("non-existing-file");
let tmp_symlink_path = tmp_dir.path().join("broken-symlink");
create_symlink(&tmp_file_path, &tmp_symlink_path);
let lscolors = LsColors::from_string("or=40;31;01:");
let style = lscolors.style_for_path(tmp_symlink_path).unwrap();
assert_eq!(Some(Color::Red), style.foreground);
}
#[test]
fn style_for_missing_file() {
let lscolors1 = LsColors::from_string("mi=01:or=33;44");
let style_missing = lscolors1
.style_for_indicator(Indicator::MissingFile)
.unwrap();
assert_eq!(FontStyle::bold(), style_missing.font_style);
let lscolors2 = LsColors::from_string("or=33;44");
let style_missing = lscolors2
.style_for_indicator(Indicator::MissingFile)
.unwrap();
assert_eq!(Some(Color::Yellow), style_missing.foreground);
let lscolors3 = LsColors::from_string("or=33;44:mi=00");
let style_missing = lscolors3
.style_for_indicator(Indicator::MissingFile)
.unwrap();
assert_eq!(Some(Color::Yellow), style_missing.foreground);
}
#[cfg(unix)]
#[test]
fn style_for_setid() {
use std::fs::{set_permissions, Permissions};
use std::os::unix::fs::PermissionsExt;
let tmp_dir = temp_dir();
let tmp_file = create_file(tmp_dir.path().join("setid"));
let perms = Permissions::from_mode(0o6750);
set_permissions(&tmp_file, perms).unwrap();
let suid_style = get_default_style(&tmp_file).unwrap();
assert_eq!(Some(Color::Red), suid_style.background);
let lscolors = LsColors::from_string("su=0");
let sgid_style = lscolors.style_for_path(&tmp_file).unwrap();
assert_eq!(Some(Color::Yellow), sgid_style.background);
}
#[cfg(unix)]
#[test]
fn style_for_multi_hard_links() {
let tmp_dir = temp_dir();
let tmp_file = create_file(tmp_dir.path().join("file1"));
std::fs::hard_link(&tmp_file, tmp_dir.path().join("file2")).unwrap();
let lscolors = LsColors::from_string("mh=35");
let style = lscolors.style_for_path(&tmp_file).unwrap();
assert_eq!(Some(Color::Magenta), style.foreground);
}
#[cfg(unix)]
#[test]
fn style_for_sticky_other_writable() {
use std::fs::{set_permissions, Permissions};
use std::os::unix::fs::PermissionsExt;
let tmp_root = temp_dir();
let tmp_dir = create_dir(tmp_root.path().join("test-dir"));
let perms = Permissions::from_mode(0o1777);
set_permissions(&tmp_dir, perms).unwrap();
let so_style = get_default_style(&tmp_dir).unwrap();
assert_eq!(Some(Color::Black), so_style.foreground);
assert_eq!(Some(Color::Green), so_style.background);
let lscolors1 = LsColors::from_string("tw=0");
let ow_style = lscolors1.style_for_path(&tmp_dir).unwrap();
assert_eq!(Some(Color::Blue), ow_style.foreground);
assert_eq!(Some(Color::Green), ow_style.background);
let lscolors2 = LsColors::from_string("tw=0:ow=0");
let st_style = lscolors2.style_for_path(&tmp_dir).unwrap();
assert_eq!(Some(Color::White), st_style.foreground);
assert_eq!(Some(Color::Blue), st_style.background);
}
#[test]
fn style_for_path_components() {
use std::ffi::OsString;
let tmp_root = temp_dir();
let tmp_dir = create_dir(tmp_root.path().join("test-dir"));
create_file(tmp_root.path().join("test-file.png"));
let tmp_symlink = tmp_root.path().join("test-symlink");
create_symlink(&tmp_dir, &tmp_symlink);
let path_via_symlink = tmp_symlink.join("test-file.png");
let lscolors = LsColors::from_string("di=34:ln=35:*.png=36");
let mut components: Vec<_> = lscolors
.style_for_path_components(&path_via_symlink)
.collect();
let (c_file, style_file) = components.pop().unwrap();
assert_eq!("test-file.png", c_file);
assert_eq!(Some(Color::Cyan), style_file.unwrap().foreground);
let (c_symlink, style_symlink) = components.pop().unwrap();
let mut expected_symlink_name = OsString::from("test-symlink");
expected_symlink_name.push(std::path::MAIN_SEPARATOR.to_string());
assert_eq!(expected_symlink_name, c_symlink);
assert_eq!(
Some(Color::Magenta),
style_symlink.cloned().and_then(|style| style.foreground)
);
let (_, style_dir) = components.pop().unwrap();
assert_eq!(Some(Color::Blue), style_dir.unwrap().foreground);
}
#[test]
fn style_for_dir_entry() {
use std::fs::read_dir;
let tmp_root = temp_dir();
create_file(tmp_root.path().join("test-file.png"));
let lscolors = LsColors::from_string("*.png=01;35");
for entry in read_dir(tmp_root.path()).unwrap() {
let style = lscolors.style_for(&entry.unwrap()).unwrap();
assert_eq!(Some(Color::Magenta), style.foreground);
}
}
#[test]
fn override_disable_suffix() {
let tmp_dir = temp_dir();
let tmp_file = create_file(tmp_dir.path().join("test-file.png"));
let lscolors = LsColors::from_string("*.png=01;35:*.png=0");
let style = lscolors.style_for_path(&tmp_file);
assert_eq!(None, style);
}
}