From a764ba0b266758258c301c5c2601009f1e12236c Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:20:10 +0900 Subject: [PATCH 1/7] fix(ls): match GNU dangling color semantics Ensure StyleManager honors `or=`, `mi=` and `ln=target` the same as GNU ls when coloring dangling symlinks. Reset sequences are still printed for blank `or=` entries, and targets fall back to missing-file colors. --- src/uu/ls/src/colors.rs | 349 ++++++++++++++++++++++++++++++++++++---- src/uu/ls/src/ls.rs | 198 +++++++++++++++++------ 2 files changed, 466 insertions(+), 81 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7f58d0fd58..a7e7e92c16f 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -3,9 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. use super::PathData; -use lscolors::{Colorable, Indicator, LsColors, Style}; +use lscolors::{Indicator, LsColors, Style}; +use std::collections::HashMap; +use std::env; use std::ffi::OsString; -use std::fs::Metadata; +use std::fs::{self, Metadata}; +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt, MetadataExt}; /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need @@ -16,49 +20,82 @@ pub(crate) struct StyleManager<'a> { /// `true` if the initial reset is applied pub(crate) initial_reset_is_done: bool, pub(crate) colors: &'a LsColors, + /// raw indicator codes as specified in LS_COLORS (if available) + indicator_codes: HashMap, + /// whether ln=target is active + ln_color_from_target: bool, } impl<'a> StyleManager<'a> { pub(crate) fn new(colors: &'a LsColors) -> Self { + let (indicator_codes, ln_color_from_target) = parse_indicator_codes(); Self { initial_reset_is_done: false, current_style: None, colors, + indicator_codes, + ln_color_from_target, } } pub(crate) fn apply_style( &mut self, new_style: Option<&Style>, + path: Option<&PathData>, name: OsString, wrap: bool, ) -> OsString { let mut style_code = String::new(); let mut force_suffix_reset: bool = false; + let mut applied_raw_code = false; - // if reset is done we need to apply normal style before applying new style if self.is_reset() { if let Some(norm_sty) = self.get_normal_style().copied() { style_code.push_str(&self.get_style_code(&norm_sty)); } } - if let Some(new_style) = new_style { - // we only need to apply a new style if it's not the same as the current - // style for example if normal is the current style and a file with - // normal style is to be printed we could skip printing new color - // codes - if !self.is_current_style(new_style) { - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(&self.get_style_code(new_style)); + if let Some(path) = path { + if let Some(indicator) = self.indicator_for_raw_code(path) { + let should_skip = indicator == Indicator::SymbolicLink + && self.ln_color_from_target + && path.path().exists(); + + if !should_skip { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { + if raw.is_empty() { + return self.apply_empty_style(name, wrap); + } + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b["); + style_code.push_str(&raw); + style_code.push('m'); + applied_raw_code = true; + self.current_style = None; + force_suffix_reset = true; + } + } } } - // if new style is None and current style is Normal we should reset it - else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) - { - style_code.push_str(self.reset(false)); - // even though this is an unnecessary reset for gnu compatibility we allow it here - force_suffix_reset = true; + + if !applied_raw_code { + if let Some(new_style) = new_style { + // we only need to apply a new style if it's not the same as the current + // style for example if normal is the current style and a file with + // normal style is to be printed we could skip printing new color + // codes + if !self.is_current_style(new_style) { + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(&self.get_style_code(new_style)); + } + } + // if new style is None and current style is Normal we should reset it + else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) + { + style_code.push_str(self.reset(false)); + // even though this is an unnecessary reset for gnu compatibility we allow it here + force_suffix_reset = true; + } } // we need this clear to eol code in some terminals, for instance if the @@ -130,17 +167,220 @@ impl<'a> StyleManager<'a> { let style = self .colors .style_for_path_with_metadata(&path.p_buf, md_option); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) } - pub(crate) fn apply_style_based_on_colorable( + pub(crate) fn apply_style_for_path( &mut self, - path: &T, + path: &PathData, name: OsString, wrap: bool, ) -> OsString { let style = self.colors.style_for(path); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) + } + + pub(crate) fn apply_indicator_style( + &mut self, + indicator: Indicator, + name: OsString, + wrap: bool, + ) -> OsString { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { + if raw.is_empty() { + return self.apply_empty_style(name, wrap); + } + + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b["); + style_code.push_str(&raw); + style_code.push('m'); + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push("\x1b[K"); + } + ret + } else { + let style = self.colors.style_for_indicator(indicator); + self.apply_style(style, None, name, wrap) + } + } + + pub(crate) fn has_indicator_style(&self, indicator: Indicator) -> bool { + self.indicator_codes.contains_key(&indicator) + || self.colors.style_for_indicator(indicator).is_some() + } + + pub(crate) fn apply_orphan_link_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } else { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } + } + + pub(crate) fn apply_missing_target_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::MissingFile) { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } else { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } + } + + fn apply_empty_style(&mut self, name: OsString, wrap: bool) -> OsString { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b[m"); + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push("\x1b[K"); + } + ret + } + + fn color_symlink_name( + &mut self, + path: &PathData, + name: OsString, + wrap: bool, + ) -> Option { + if path.must_dereference && path.metadata().is_none() { + return None; + } + let mut target = path.path().read_link().ok()?; + if target.is_relative() { + if let Some(parent) = path.path().parent() { + target = parent.join(target); + } + } + + match fs::metadata(&target) { + Ok(metadata) => { + if self.ln_color_from_target { + let style = self + .colors + .style_for_path_with_metadata(&target, Some(&metadata)); + Some(self.apply_style(style, None, name, wrap)) + } else { + None + } + } + Err(_) => { + if self.ln_color_from_target { + Some(self.apply_orphan_link_style(name, wrap)) + } else { + None + } + } + } + } + + fn indicator_has(&self, indicator: Indicator) -> bool { + self.indicator_codes.contains_key(&indicator) + } + + fn indicator_for_raw_code(&self, path: &PathData) -> Option { + if self.indicator_codes.is_empty() { + return None; + } + + let exists = path.path().exists(); + let Some(file_type) = path.file_type() else { + if self.indicator_has(Indicator::MissingFile) && !exists { + return Some(Indicator::MissingFile); + } + return None; + }; + + if file_type.is_symlink() { + let orphan_style = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_has_color = orphan_style.map(|s| !s.is_empty()).unwrap_or(false); + if !exists && (orphan_has_color || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.indicator_has(Indicator::SymbolicLink) { + return Some(Indicator::SymbolicLink); + } + if !exists && self.indicator_has(Indicator::MissingFile) { + return Some(Indicator::MissingFile); + } + return None; + } + if self.indicator_has(Indicator::MissingFile) && !exists { + return Some(Indicator::MissingFile); + } + + if file_type.is_file() { + #[cfg(unix)] + { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.indicator_has(Indicator::Setuid) && mode & 0o4000 != 0 { + return Some(Indicator::Setuid); + } + if self.indicator_has(Indicator::Setgid) && mode & 0o2000 != 0 { + return Some(Indicator::Setgid); + } + if self.indicator_has(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + return Some(Indicator::ExecutableFile); + } + if self.indicator_has(Indicator::MultipleHardLinks) && metadata.nlink() > 1 { + return Some(Indicator::MultipleHardLinks); + } + } + } + + if self.indicator_has(Indicator::RegularFile) { + return Some(Indicator::RegularFile); + } + } else if file_type.is_dir() { + #[cfg(unix)] + { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.indicator_has(Indicator::StickyAndOtherWritable) + && mode & 0o1002 == 0o1002 + { + return Some(Indicator::StickyAndOtherWritable); + } + if self.indicator_has(Indicator::OtherWritable) && mode & 0o0002 != 0 { + return Some(Indicator::OtherWritable); + } + if self.indicator_has(Indicator::Sticky) && mode & 0o1000 != 0 { + return Some(Indicator::Sticky); + } + } + } + + if self.indicator_has(Indicator::Directory) { + return Some(Indicator::Directory); + } + } else { + #[cfg(unix)] + { + if file_type.is_fifo() && self.indicator_has(Indicator::FIFO) { + return Some(Indicator::FIFO); + } + if file_type.is_socket() && self.indicator_has(Indicator::Socket) { + return Some(Indicator::Socket); + } + if file_type.is_block_device() && self.indicator_has(Indicator::BlockDevice) { + return Some(Indicator::BlockDevice); + } + if file_type.is_char_device() && self.indicator_has(Indicator::CharacterDevice) { + return Some(Indicator::CharacterDevice); + } + } + } + + None } } @@ -168,27 +408,70 @@ pub(crate) fn color_name( // If the file has capabilities, use a specific style for `ca` (capabilities) if has_capabilities { - return style_manager.apply_style(capabilities, name, wrap); + return style_manager.apply_style(capabilities, Some(path), name, wrap); } } - if !path.must_dereference { - // If we need to dereference (follow) a symlink, we will need to get the metadata - // There is a DirEntry, we don't need to get the metadata for the color - return style_manager.apply_style_based_on_colorable(path, name, wrap); + if target_symlink.is_none() && path.file_type().is_some_and(|ft| ft.is_symlink()) { + if let Some(colored) = style_manager.color_symlink_name(path, name.clone(), wrap) { + return colored; + } } if let Some(target) = target_symlink { // use the optional target_symlink // Use fn symlink_metadata directly instead of get_metadata() here because ls // should not exit with an err, if we are unable to obtain the target_metadata - style_manager.apply_style_based_on_colorable(target, name, wrap) - } else { - let md_option: Option = path - .metadata() - .cloned() - .or_else(|| path.p_buf.symlink_metadata().ok()); + return style_manager.apply_style_for_path(target, name, wrap); + } + + if !path.must_dereference { + // If we need to dereference (follow) a symlink, we will need to get the metadata + // There is a DirEntry, we don't need to get the metadata for the color + return style_manager.apply_style_for_path(path, name, wrap); + } + + let md_option: Option = path + .metadata() + .cloned() + .or_else(|| path.p_buf.symlink_metadata().ok()); - style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) + style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) +} + +fn parse_indicator_codes() -> (HashMap, bool) { + let mut indicator_codes = HashMap::new(); + let mut ln_color_from_target = false; + + if let Ok(ls_colors) = env::var("LS_COLORS") { + for entry in ls_colors.split(':') { + if entry.is_empty() { + continue; + } + let Some((key, value)) = entry.split_once('=') else { + continue; + }; + + if let Some(indicator) = Indicator::from(key) { + if indicator == Indicator::SymbolicLink && value == "target" { + ln_color_from_target = true; + continue; + } + indicator_codes.insert(indicator, canonicalize_indicator_value(value)); + } + } + } + + (indicator_codes, ln_color_from_target) +} + +fn canonicalize_indicator_value(value: &str) -> String { + if value.len() == 1 && value.chars().all(|c| c.is_ascii_digit()) { + let mut canonical = String::with_capacity(2); + canonical.push('0'); + canonical.push_str(value); + canonical + } else { + value.to_string() } } diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 6f038142ab7..923f9319d6f 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -3,7 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly nohash strtime +// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly +// spell-checker:ignore nohash strtime clocale #[cfg(unix)] use fnv::FnvHashMap as HashMap; @@ -18,7 +19,7 @@ use std::{ cell::{LazyCell, OnceCell}, cmp::Reverse, ffi::{OsStr, OsString}, - fmt::Write as FmtWrite, + fmt::Write as _, fs::{self, DirEntry, FileType, Metadata, ReadDir}, io::{BufWriter, ErrorKind, IsTerminal, Stdout, Write, stdout}, iter, @@ -338,6 +339,12 @@ enum IndicatorStyle { Classify, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum LocaleQuoting { + Single, + Double, +} + pub struct Config { // Dir and vdir needs access to this field pub format: Format, @@ -361,6 +368,7 @@ pub struct Config { width: u16, // Dir and vdir needs access to this field pub quoting_style: QuotingStyle, + locale_quoting: Option, indicator_style: IndicatorStyle, time_format_recent: String, // Time format for recent dates time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) @@ -654,18 +662,43 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { /// # Returns /// /// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. -fn match_quoting_style_name(style: &str, show_control: bool) -> Option { - match style { - "literal" => Some(QuotingStyle::Literal { show_control }), - "shell" => Some(QuotingStyle::SHELL), - "shell-always" => Some(QuotingStyle::SHELL_QUOTE), - "shell-escape" => Some(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => Some(QuotingStyle::SHELL_ESCAPE_QUOTE), - "c" => Some(QuotingStyle::C_DOUBLE), - "escape" => Some(QuotingStyle::C_NO_QUOTES), - _ => None, - } - .map(|qs| qs.show_control(show_control)) +fn match_quoting_style_name( + style: &str, + show_control: bool, +) -> Option<(QuotingStyle, Option)> { + let (qs, fixed_control, locale) = match style { + "literal" => ( + QuotingStyle::Literal { + show_control: false, + }, + false, + None, + ), + "shell" => (QuotingStyle::SHELL, false, None), + "shell-always" => (QuotingStyle::SHELL_QUOTE, false, None), + "shell-escape" => (QuotingStyle::SHELL_ESCAPE, false, None), + "shell-escape-always" => (QuotingStyle::SHELL_ESCAPE_QUOTE, false, None), + "c" => (QuotingStyle::C_DOUBLE, false, None), + "escape" => (QuotingStyle::C_NO_QUOTES, false, None), + "locale" => ( + QuotingStyle::Literal { + show_control: false, + }, + true, + Some(LocaleQuoting::Single), + ), + "clocale" => (QuotingStyle::C_DOUBLE, true, Some(LocaleQuoting::Double)), + _ => return None, + }; + + Some(( + if fixed_control { + qs + } else { + qs.show_control(show_control) + }, + locale, + )) } /// Extracts the quoting style to use based on the options provided. @@ -680,27 +713,30 @@ fn match_quoting_style_name(style: &str, show_control: bool) -> Option QuotingStyle { +fn extract_quoting_style( + options: &clap::ArgMatches, + show_control: bool, +) -> (QuotingStyle, Option) { let opt_quoting_style = options.get_one::(QUOTING_STYLE); if let Some(style) = opt_quoting_style { match match_quoting_style_name(style, show_control) { - Some(qs) => qs, + Some(pair) => pair, None => unreachable!("Should have been caught by Clap"), } } else if options.get_flag(options::quoting::LITERAL) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else if options.get_flag(options::quoting::ESCAPE) { - QuotingStyle::C_NO_QUOTES + (QuotingStyle::C_NO_QUOTES, None) } else if options.get_flag(options::quoting::C) { - QuotingStyle::C_DOUBLE + (QuotingStyle::C_DOUBLE, None) } else if options.get_flag(options::DIRED) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else { // If set, the QUOTING_STYLE environment variable specifies a default style. if let Ok(style) = std::env::var("QUOTING_STYLE") { match match_quoting_style_name(style.as_str(), show_control) { - Some(qs) => return qs, + Some(pair) => return pair, None => eprintln!( "{}", translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) @@ -711,9 +747,9 @@ fn extract_quoting_style(options: &clap::ArgMatches, show_control: bool) -> Quot // By default, `ls` uses Shell escape quoting style when writing to a terminal file // descriptor and Literal otherwise. if stdout().is_terminal() { - QuotingStyle::SHELL_ESCAPE.show_control(show_control) + (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) } else { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } } } @@ -969,7 +1005,7 @@ impl Config { !stdout().is_terminal() }; - let mut quoting_style = extract_quoting_style(options, show_control); + let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); let indicator_style = extract_indicator_style(options); // Only parse the value to "--time-style" if it will become relevant. let dired = options.get_flag(options::DIRED); @@ -1092,6 +1128,7 @@ impl Config { .unwrap_or(0) { quoting_style = QuotingStyle::Literal { show_control }; + locale_quoting = None; } let color = if needs_color { @@ -1155,6 +1192,7 @@ impl Config { block_size, width, quoting_style, + locale_quoting, indicator_style, time_format_recent, time_format_older, @@ -1363,10 +1401,12 @@ pub fn uu_app() -> Command { .help(translate!("ls-help-set-quoting-style")) .value_parser(ShortcutValueParser::new([ PossibleValue::new("literal"), + PossibleValue::new("locale"), PossibleValue::new("shell"), PossibleValue::new("shell-escape"), PossibleValue::new("shell-always"), PossibleValue::new("shell-escape-always"), + PossibleValue::new("clocale"), PossibleValue::new("c").alias("c-maybe"), PossibleValue::new("escape"), ])) @@ -2039,8 +2079,7 @@ fn show_dir_name( out: &mut BufWriter, config: &Config, ) -> std::io::Result<()> { - let escaped_name = - locale_aware_escape_dir_name(path_data.path().as_os_str(), config.quoting_style); + let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); let name = if config.hyperlink && !config.dired { create_hyperlink(&escaped_name, path_data) @@ -2052,6 +2091,67 @@ fn show_dir_name( write!(out, ":") } +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + locale_aware_escape_dir_name(name, config.quoting_style) + } +} + +fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + locale_aware_escape_name(name, config.quoting_style) + } +} + +fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { + let bytes = os_str_as_bytes_lossy(name); + let mut quoted = String::new(); + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + for &byte in bytes.as_ref() { + push_locale_byte(&mut quoted, byte, style); + } + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + OsString::from(quoted) +} + +fn push_locale_byte(buf: &mut String, byte: u8, style: LocaleQuoting) { + match (style, byte) { + (LocaleQuoting::Single, b'\'') => buf.push_str("'\\''"), + (LocaleQuoting::Double, b'"') => buf.push_str("\\\""), + (_, b'\\') => buf.push_str("\\\\"), + _ => push_basic_escape(buf, byte), + } +} + +fn push_basic_escape(buf: &mut String, byte: u8) { + match byte { + b'\x07' => buf.push_str("\\a"), + b'\x08' => buf.push_str("\\b"), + b'\t' => buf.push_str("\\t"), + b'\n' => buf.push_str("\\n"), + b'\x0b' => buf.push_str("\\v"), + b'\x0c' => buf.push_str("\\f"), + b'\r' => buf.push_str("\\r"), + b'\x1b' => buf.push_str("\\e"), + b'"' => buf.push('"'), + b'\'' => buf.push('\''), + b if (0x20..=0x7e).contains(&b) => buf.push(b as char), + _ => { + let _ = write!(buf, "\\{byte:03o}"); + } + } +} + // A struct to encapsulate state that is passed around from `list` functions. struct ListState<'a> { out: BufWriter, @@ -2546,7 +2646,7 @@ fn display_items( // option, print the security context to the left of the size column. let quoted = items.iter().any(|item| { - let name = locale_aware_escape_name(item.display_name(), config.quoting_style); + let name = escape_name_with_locale(item.display_name(), config); os_str_starts_with(&name, b"'") }); @@ -3192,7 +3292,7 @@ fn display_item_name( current_column: LazyCell usize + '_>>, ) -> OsString { // This is our return value. We start by `&path.display_name` and modify it along the way. - let mut name = locale_aware_escape_name(path.display_name(), config.quoting_style); + let mut name = escape_name_with_locale(path.display_name(), config); let is_wrap = |namelen: usize| config.width != 0 && *current_column + namelen > config.width.into(); @@ -3253,6 +3353,7 @@ fn display_item_name( // This makes extra system calls, but provides important information that // people run `ls -l --color` are very interested in. if let Some(style_manager) = &mut state.style_manager { + let escaped_target = escape_name_with_locale(target_path.as_os_str(), config); // We get the absolute path to be able to construct PathData with valid Metadata. // This is because relative symlinks will fail to get_metadata. let mut absolute_target = target_path.clone(); @@ -3262,30 +3363,31 @@ fn display_item_name( } } - let target_data = PathData::new(absolute_target, None, None, config, false); - - // If we have a symlink to a valid file, we use the metadata of said file. - // Because we use an absolute path, we can assume this is guaranteed to exist. - // Otherwise, we use path.md(), which will guarantee we color to the same - // color of non-existent symlinks according to style_for_path_with_metadata. - if path.metadata().is_none() && target_data.metadata().is_none() { - name.push(target_path); - } else { - name.push(color_name( - locale_aware_escape_name(target_path.as_os_str(), config.quoting_style), - path, - style_manager, - Some(&target_data), - is_wrap(name.len()), - )); + match fs::metadata(&absolute_target) { + Ok(_) => { + let target_data = + PathData::new(absolute_target, None, None, config, false); + name.push(color_name( + escaped_target, + &target_data, + style_manager, + None, + is_wrap(name.len()), + )); + } + Err(_) => { + name.push( + style_manager.apply_missing_target_style( + escaped_target, + is_wrap(name.len()), + ), + ); + } } } else { // If no coloring is required, we just use target as is. // Apply the right quoting - name.push(locale_aware_escape_name( - target_path.as_os_str(), - config.quoting_style, - )); + name.push(escape_name_with_locale(target_path.as_os_str(), config)); } } Err(err) => { From 90e54fbbe3458804fb1f486b95e6e44a1c39f26d Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:21:34 +0900 Subject: [PATCH 2/7] test: cover GNU dangling symlink cases Add seven ls tests mirroring GNU tests/ls/ls-misc.pl sl-dangle3..9 to validate combinations of `ln=`, `or=` and `mi=` settings. The tests verify both file-level output and directory listings, including the `\x1b[m` reset requirement when `or=:` is set. Adjust existing Rust tests to GNU output. Existing color tests were updated to match GNU output. --- tests/by-util/test_ls.rs | 222 +++++++++++++++++++++++++++++++++++---- 1 file changed, 201 insertions(+), 21 deletions(-) diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index ef7591b8ae2..293f9619290 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore (words) READMECAREFULLY birthtime doesntexist oneline somebackup lrwx somefile somegroup somehiddenbackup somehiddenfile tabsize aaaaaaaa bbbb cccc dddddddd ncccc neee naaaaa nbcdef nfffff dired subdired tmpfs mdir COLORTERM mexe bcdef mfoo timefile -// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps +// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry #![allow( clippy::similar_names, clippy::too_many_lines, @@ -1446,31 +1446,213 @@ fn test_ls_long_dangling_symlink_color() { at.mkdir("dir1"); at.symlink_dir("foo", "dir1/dangling_symlink"); + let ls_colors = "ln=target:or=40:mi=34"; let result = ts .ucmd() + .env("LS_COLORS", ls_colors) .arg("-l") .arg("--color=always") .arg("dir1/dangling_symlink") .succeeds(); let stdout = result.stdout_str(); - // stdout contains output like in the below sequence. We match for the color i.e. 01;36 - // \x1b[0m\x1b[01;36mdir1/dangling_symlink\x1b[0m -> \x1b[01;36mfoo\x1b[0m - let color_regex = Regex::new(r"(\d\d;)\d\dm").unwrap(); - // colors_vec[0] contains the symlink color and style and colors_vec[1] contains the color and style of the file the - // symlink points to. - let colors_vec: Vec<_> = color_regex - .find_iter(stdout) - .map(|color| color.as_str()) - .collect(); + // Ensure dangling link name uses `or=` and target uses `mi=`. + let name_regex = + Regex::new(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)mdir1/dangling_symlink\x1b\[0m").unwrap(); + let target_path = regex::escape(&at.plus_as_string("foo")); + let target_pattern = format!(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)m{target_path}\x1b\[0m"); + let target_regex = Regex::new(&target_pattern).unwrap(); + + let name_caps = name_regex + .captures(stdout) + .expect("failed to capture dangling symlink name color"); + let target_caps = target_regex + .captures(stdout) + .expect("failed to capture dangling target color"); + + let name_color = name_caps.get(1).unwrap().as_str(); + let target_color = target_caps.get(1).unwrap().as_str(); + + assert_eq!(name_color, "40"); + assert_eq!(target_color, "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle3`. +fn test_ls_dangling_symlink_or_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=40:mi=34") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); - assert_eq!(colors_vec[0], colors_vec[1]); - // constructs the string of file path with the color code - let symlink_color_name = colors_vec[0].to_owned() + "dir1/dangling_symlink\x1b"; - let target_color_name = colors_vec[1].to_owned() + at.plus_as_string("foo\x1b").as_str(); + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); - assert!(stdout.contains(&symlink_color_name)); - assert!(stdout.contains(&target_color_name)); + assert_eq!(captures.name("link").unwrap().as_str(), "40"); + assert_eq!(captures.name("target").unwrap().as_str(), "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle4`. +fn test_ls_dangling_symlink_ln_or_priority() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35:or=36") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "36"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle5`. +fn test_ls_dangling_symlink_ln_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "34"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle7`. +fn test_ls_dangling_symlink_blank_or_still_emits_reset() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle9`. +fn test_ls_dangling_symlink_blank_or_in_directory_listing() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle8`. +fn test_ls_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle6`. +fn test_ls_directory_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); } #[test] @@ -5862,8 +6044,7 @@ fn test_ls_color_norm() { .stdout_contains(expected); // uncolored ordinary files that do _not_ inherit from NORMAL. - let expected = - "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=")) @@ -5876,8 +6057,7 @@ fn test_ls_color_norm() { .stdout_str_apply(strip) .stdout_contains(expected); - let expected = - "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[00mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=0")) @@ -6498,7 +6678,7 @@ fn test_f_overrides_sort_flags() { // Create files with different sizes for predictable sort order at.write("small.txt", "a"); // 1 byte - at.write("medium.txt", "bb"); // 2 bytes + at.write("medium.txt", "bb"); // 2 bytes at.write("large.txt", "ccc"); // 3 bytes // Get baseline outputs (include -a to match -f behavior which shows all files) From 692c1a1b0b534359bb9529f46e50ea25849d3009 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:14:00 +0900 Subject: [PATCH 3/7] fix(ls): align LS_COLORS handling with GNU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colors: make StyleManager rely on lscolors’ explicit-style flags, avoid unnecessary metadata/stat probes, and ensure ln=target, or=, mi= honor GNU coloring semantics. Tests: update test_ls_color_norm expectations so the fi= and fi=0 cases now match GNU ls output, ensuring the new color handling is verified. --- src/uu/ls/src/colors.rs | 168 +++++++++++++++++++++++++++++---------- tests/by-util/test_ls.rs | 6 +- 2 files changed, 132 insertions(+), 42 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7e7e92c16f..162baf301f5 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -212,7 +212,7 @@ impl<'a> StyleManager<'a> { pub(crate) fn has_indicator_style(&self, indicator: Indicator) -> bool { self.indicator_codes.contains_key(&indicator) - || self.colors.style_for_indicator(indicator).is_some() + || self.colors.has_explicit_style_for(indicator) } pub(crate) fn apply_orphan_link_style(&mut self, name: OsString, wrap: bool) -> OsString { @@ -251,6 +251,9 @@ impl<'a> StyleManager<'a> { name: OsString, wrap: bool, ) -> Option { + if !self.ln_color_from_target { + return None; + } if path.must_dereference && path.metadata().is_none() { return None; } @@ -263,17 +266,13 @@ impl<'a> StyleManager<'a> { match fs::metadata(&target) { Ok(metadata) => { - if self.ln_color_from_target { - let style = self - .colors - .style_for_path_with_metadata(&target, Some(&metadata)); - Some(self.apply_style(style, None, name, wrap)) - } else { - None - } + let style = self + .colors + .style_for_path_with_metadata(&target, Some(&metadata)); + Some(self.apply_style(style, None, name, wrap)) } Err(_) => { - if self.ln_color_from_target { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { Some(self.apply_orphan_link_style(name, wrap)) } else { None @@ -282,99 +281,110 @@ impl<'a> StyleManager<'a> { } } - fn indicator_has(&self, indicator: Indicator) -> bool { - self.indicator_codes.contains_key(&indicator) - } - fn indicator_for_raw_code(&self, path: &PathData) -> Option { if self.indicator_codes.is_empty() { return None; } - let exists = path.path().exists(); + let mut existence_cache: Option = None; + let mut entry_exists = + || -> bool { *existence_cache.get_or_insert_with(|| path.path().exists()) }; + let Some(file_type) = path.file_type() else { - if self.indicator_has(Indicator::MissingFile) && !exists { + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { return Some(Indicator::MissingFile); } return None; }; if file_type.is_symlink() { - let orphan_style = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); - let orphan_has_color = orphan_style.map(|s| !s.is_empty()).unwrap_or(false); - if !exists && (orphan_has_color || self.ln_color_from_target) { - return Some(Indicator::OrphanedSymbolicLink); + let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); + let missing_enabled = self.has_indicator_style(Indicator::MissingFile); + let needs_target_state = self.ln_color_from_target || orphan_enabled; + let target_missing = needs_target_state && !entry_exists(); + + if target_missing { + let orphan_raw = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_raw_is_empty = orphan_raw.is_some_and(|value| value.is_empty()); + if orphan_enabled && (!orphan_raw_is_empty || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.ln_color_from_target && missing_enabled { + return Some(Indicator::MissingFile); + } } - if self.indicator_has(Indicator::SymbolicLink) { + if self.has_indicator_style(Indicator::SymbolicLink) { return Some(Indicator::SymbolicLink); } - if !exists && self.indicator_has(Indicator::MissingFile) { - return Some(Indicator::MissingFile); - } return None; } - if self.indicator_has(Indicator::MissingFile) && !exists { + + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { return Some(Indicator::MissingFile); } if file_type.is_file() { #[cfg(unix)] - { + if self.needs_file_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.indicator_has(Indicator::Setuid) && mode & 0o4000 != 0 { + if self.has_indicator_style(Indicator::Setuid) && mode & 0o4000 != 0 { return Some(Indicator::Setuid); } - if self.indicator_has(Indicator::Setgid) && mode & 0o2000 != 0 { + if self.has_indicator_style(Indicator::Setgid) && mode & 0o2000 != 0 { return Some(Indicator::Setgid); } - if self.indicator_has(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + if self.has_indicator_style(Indicator::ExecutableFile) && mode & 0o0111 != 0 { return Some(Indicator::ExecutableFile); } - if self.indicator_has(Indicator::MultipleHardLinks) && metadata.nlink() > 1 { + if self.has_indicator_style(Indicator::MultipleHardLinks) + && metadata.nlink() > 1 + { return Some(Indicator::MultipleHardLinks); } } } - if self.indicator_has(Indicator::RegularFile) { + if self.has_indicator_style(Indicator::RegularFile) { return Some(Indicator::RegularFile); } } else if file_type.is_dir() { #[cfg(unix)] - { + if self.needs_dir_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.indicator_has(Indicator::StickyAndOtherWritable) + if self.has_indicator_style(Indicator::StickyAndOtherWritable) && mode & 0o1002 == 0o1002 { return Some(Indicator::StickyAndOtherWritable); } - if self.indicator_has(Indicator::OtherWritable) && mode & 0o0002 != 0 { + if self.has_indicator_style(Indicator::OtherWritable) && mode & 0o0002 != 0 { return Some(Indicator::OtherWritable); } - if self.indicator_has(Indicator::Sticky) && mode & 0o1000 != 0 { + if self.has_indicator_style(Indicator::Sticky) && mode & 0o1000 != 0 { return Some(Indicator::Sticky); } } } - if self.indicator_has(Indicator::Directory) { + if self.has_indicator_style(Indicator::Directory) { return Some(Indicator::Directory); } } else { #[cfg(unix)] { - if file_type.is_fifo() && self.indicator_has(Indicator::FIFO) { + if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { return Some(Indicator::FIFO); } - if file_type.is_socket() && self.indicator_has(Indicator::Socket) { + if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { return Some(Indicator::Socket); } - if file_type.is_block_device() && self.indicator_has(Indicator::BlockDevice) { + if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { return Some(Indicator::BlockDevice); } - if file_type.is_char_device() && self.indicator_has(Indicator::CharacterDevice) { + if file_type.is_char_device() + && self.has_indicator_style(Indicator::CharacterDevice) + { return Some(Indicator::CharacterDevice); } } @@ -382,6 +392,21 @@ impl<'a> StyleManager<'a> { None } + + #[cfg(unix)] + fn needs_file_metadata(&self) -> bool { + self.has_indicator_style(Indicator::Setuid) + || self.has_indicator_style(Indicator::Setgid) + || self.has_indicator_style(Indicator::ExecutableFile) + || self.has_indicator_style(Indicator::MultipleHardLinks) + } + + #[cfg(unix)] + fn needs_dir_metadata(&self) -> bool { + self.has_indicator_style(Indicator::StickyAndOtherWritable) + || self.has_indicator_style(Indicator::OtherWritable) + || self.has_indicator_style(Indicator::Sticky) + } } /// Colors the provided name based on the style determined for the given path @@ -457,6 +482,17 @@ fn parse_indicator_codes() -> (HashMap, bool) { ln_color_from_target = true; continue; } + if indicator_value_is_disabled(indicator, value) { + if value.is_empty() + && matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + { + indicator_codes.insert(indicator, String::new()); + } + continue; + } indicator_codes.insert(indicator, canonicalize_indicator_value(value)); } } @@ -475,3 +511,55 @@ fn canonicalize_indicator_value(value: &str) -> String { value.to_string() } } + +fn indicator_value_is_disabled(indicator: Indicator, value: &str) -> bool { + if value.is_empty() { + !matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + } else { + value.chars().all(|c| c == '0') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn style_manager( + colors: &LsColors, + indicator_codes: HashMap, + ) -> StyleManager<'_> { + StyleManager { + current_style: None, + initial_reset_is_done: false, + colors, + indicator_codes, + ln_color_from_target: false, + } + } + + #[test] + fn has_indicator_style_ignores_fallback_styles() { + let colors = LsColors::from_string("ex=00:fi=32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(!manager.has_indicator_style(Indicator::ExecutableFile)); + } + + #[test] + fn has_indicator_style_detects_explicit_styles() { + let colors = LsColors::from_string("ex=01;32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(manager.has_indicator_style(Indicator::ExecutableFile)); + } + + #[test] + fn has_indicator_style_detects_raw_codes() { + let colors = LsColors::empty(); + let mut indicator_codes = HashMap::new(); + indicator_codes.insert(Indicator::Directory, "01;34".to_string()); + let manager = style_manager(&colors, indicator_codes); + assert!(manager.has_indicator_style(Indicator::Directory)); + } +} diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 293f9619290..837c1277f1b 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -6044,7 +6044,8 @@ fn test_ls_color_norm() { .stdout_contains(expected); // uncolored ordinary files that do _not_ inherit from NORMAL. - let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = + "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=")) @@ -6057,7 +6058,8 @@ fn test_ls_color_norm() { .stdout_str_apply(strip) .stdout_contains(expected); - let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[00mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = + "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=0")) From c365b4416129059b3c6dc0acd3a163f3b249e201 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:56:05 +0900 Subject: [PATCH 4/7] lint(ls): clarify ANSI escape handling - factor ANSI escape literals into named constants - document why we bypass LsColors fallbacks when applying raw SGR codes --- src/uu/ls/src/colors.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 162baf301f5..62ebd5b5aad 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -11,6 +11,13 @@ use std::fs::{self, Metadata}; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; +/// ANSI CSI (Control Sequence Introducer) +const ANSI_CSI: &str = "\x1b["; +const ANSI_SGR_END: &str = "m"; +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_CLEAR_EOL: &str = "\x1b[K"; +const EMPTY_STYLE: &str = "\x1b[m"; + /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need /// the reset @@ -56,6 +63,9 @@ impl<'a> StyleManager<'a> { } if let Some(path) = path { + // Fast-path: apply LS_COLORS raw SGR codes verbatim, + // bypassing LsColors fallbacks so the entry from LS_COLORS + // is honored exactly as specified. if let Some(indicator) = self.indicator_for_raw_code(path) { let should_skip = indicator == Indicator::SymbolicLink && self.ln_color_from_target @@ -67,9 +77,9 @@ impl<'a> StyleManager<'a> { return self.apply_empty_style(name, wrap); } style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b["); + style_code.push_str(ANSI_CSI); style_code.push_str(&raw); - style_code.push('m'); + style_code.push_str(ANSI_SGR_END); applied_raw_code = true; self.current_style = None; force_suffix_reset = true; @@ -103,7 +113,7 @@ impl<'a> StyleManager<'a> { // scroll up in order to print new text in this situation if the clear // to eol code is not present the background of the text would stretch // till the end of line - let clear_to_eol = if wrap { "\x1b[K" } else { "" }; + let clear_to_eol = if wrap { ANSI_CLEAR_EOL } else { "" }; let mut ret: OsString = style_code.into(); ret.push(name); @@ -124,7 +134,7 @@ impl<'a> StyleManager<'a> { if self.current_style.is_some() || force { self.initial_reset_is_done = true; self.current_style = None; - return "\x1b[0m"; + return ANSI_RESET; } "" } @@ -193,15 +203,15 @@ impl<'a> StyleManager<'a> { let mut style_code = String::new(); style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b["); + style_code.push_str(ANSI_CSI); style_code.push_str(&raw); - style_code.push('m'); + style_code.push_str(ANSI_SGR_END); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); if wrap { - ret.push("\x1b[K"); + ret.push(ANSI_CLEAR_EOL); } ret } else { @@ -234,13 +244,13 @@ impl<'a> StyleManager<'a> { fn apply_empty_style(&mut self, name: OsString, wrap: bool) -> OsString { let mut style_code = String::new(); style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b[m"); + style_code.push_str(EMPTY_STYLE); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); if wrap { - ret.push("\x1b[K"); + ret.push(ANSI_CLEAR_EOL); } ret } From af8afe0fd55f0b0e36b1a496a7eac829fcd41f1a Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:38:51 +0900 Subject: [PATCH 5/7] refactor(ls): structure quoting style match --- src/uu/ls/src/ls.rs | 75 ++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 923f9319d6f..0bbb6bac55a 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -662,43 +662,62 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { /// # Returns /// /// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. +struct QuotingStyleSpec { + style: QuotingStyle, + fixed_control: bool, + locale: Option, +} + +impl QuotingStyleSpec { + fn new(style: QuotingStyle) -> Self { + Self { + style, + fixed_control: false, + locale: None, + } + } + + fn with_locale(style: QuotingStyle, locale: LocaleQuoting) -> Self { + Self { + style, + fixed_control: true, + locale: Some(locale), + } + } +} + fn match_quoting_style_name( style: &str, show_control: bool, ) -> Option<(QuotingStyle, Option)> { - let (qs, fixed_control, locale) = match style { - "literal" => ( - QuotingStyle::Literal { - show_control: false, - }, - false, - None, - ), - "shell" => (QuotingStyle::SHELL, false, None), - "shell-always" => (QuotingStyle::SHELL_QUOTE, false, None), - "shell-escape" => (QuotingStyle::SHELL_ESCAPE, false, None), - "shell-escape-always" => (QuotingStyle::SHELL_ESCAPE_QUOTE, false, None), - "c" => (QuotingStyle::C_DOUBLE, false, None), - "escape" => (QuotingStyle::C_NO_QUOTES, false, None), - "locale" => ( - QuotingStyle::Literal { + let spec = match style { + "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { + show_control: false, + }), + "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), + "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), + "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), + "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), + "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), + "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), + "locale" => QuotingStyleSpec { + style: QuotingStyle::Literal { show_control: false, }, - true, - Some(LocaleQuoting::Single), - ), - "clocale" => (QuotingStyle::C_DOUBLE, true, Some(LocaleQuoting::Double)), + fixed_control: true, + locale: Some(LocaleQuoting::Single), + }, + "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), _ => return None, }; - Some(( - if fixed_control { - qs - } else { - qs.show_control(show_control) - }, - locale, - )) + let style = if spec.fixed_control { + spec.style + } else { + spec.style.show_control(show_control) + }; + + Some((style, spec.locale)) } /// Extracts the quoting style to use based on the options provided. From 447ab48324e89c4eece5626ca80558a7f2f63218 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:52:48 +0900 Subject: [PATCH 6/7] ls: validate LS_COLORS and refactor styles Add GNU-like LS_COLORS validation and disable color output on parse errors with warnings. Refactor raw style application and fallback handling to reduce complexity and document empty entries. De-duplicate locale escaping and add warning strings for LS_COLORS errors. --- src/uu/ls/locales/en-US.ftl | 2 + src/uu/ls/locales/fr-FR.ftl | 2 + src/uu/ls/src/colors.rs | 291 ++++++++++++++++++++++++++++++------ src/uu/ls/src/ls.rs | 35 ++++- 4 files changed, 280 insertions(+), 50 deletions(-) diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index d5fc32b4f27..004243c5ed6 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -123,6 +123,8 @@ ls-invalid-quoting-style = {$program}: Ignoring invalid value of environment var ls-invalid-columns-width = ignoring invalid width in environment variable COLUMNS: {$width} ls-invalid-ignore-pattern = Invalid pattern for ignore: {$pattern} ls-invalid-hide-pattern = Invalid pattern for hide: {$pattern} +ls-warning-unrecognized-ls-colors-prefix = unrecognized prefix: {$prefix} +ls-warning-unparsable-ls-colors = unparsable value for LS_COLORS environment variable ls-total = total {$size} # Security context warnings diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 552e4095fbb..0ae8b06c995 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -123,4 +123,6 @@ ls-invalid-quoting-style = {$program} : Ignorer la valeur invalide de la variabl ls-invalid-columns-width = ignorer la largeur invalide dans la variable d'environnement COLUMNS : {$width} ls-invalid-ignore-pattern = Motif invalide pour ignore : {$pattern} ls-invalid-hide-pattern = Motif invalide pour hide : {$pattern} +ls-warning-unrecognized-ls-colors-prefix = préfixe non reconnu : {$prefix} +ls-warning-unparsable-ls-colors = valeur illisible pour la variable d'environnement LS_COLORS ls-total = total {$size} diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 62ebd5b5aad..b8c1da96bcd 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -18,6 +18,11 @@ const ANSI_RESET: &str = "\x1b[0m"; const ANSI_CLEAR_EOL: &str = "\x1b[K"; const EMPTY_STYLE: &str = "\x1b[m"; +enum RawIndicatorStyle { + Empty, + Code(String), +} + /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need /// the reset @@ -66,46 +71,24 @@ impl<'a> StyleManager<'a> { // Fast-path: apply LS_COLORS raw SGR codes verbatim, // bypassing LsColors fallbacks so the entry from LS_COLORS // is honored exactly as specified. - if let Some(indicator) = self.indicator_for_raw_code(path) { - let should_skip = indicator == Indicator::SymbolicLink - && self.ln_color_from_target - && path.path().exists(); - - if !should_skip { - if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { - if raw.is_empty() { - return self.apply_empty_style(name, wrap); - } - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(ANSI_CSI); - style_code.push_str(&raw); - style_code.push_str(ANSI_SGR_END); - applied_raw_code = true; - self.current_style = None; - force_suffix_reset = true; - } + match self.raw_indicator_style_for_path(path) { + Some(RawIndicatorStyle::Empty) => { + // An explicit empty entry (e.g. "or=") disables coloring and + // bypasses fallbacks, matching GNU ls behavior. + return self.apply_empty_style(name, wrap); } + Some(RawIndicatorStyle::Code(raw)) => { + style_code.push_str(&self.build_raw_style_code(&raw)); + applied_raw_code = true; + self.current_style = None; + force_suffix_reset = true; + } + None => {} } } if !applied_raw_code { - if let Some(new_style) = new_style { - // we only need to apply a new style if it's not the same as the current - // style for example if normal is the current style and a file with - // normal style is to be printed we could skip printing new color - // codes - if !self.is_current_style(new_style) { - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(&self.get_style_code(new_style)); - } - } - // if new style is None and current style is Normal we should reset it - else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) - { - style_code.push_str(self.reset(false)); - // even though this is an unnecessary reset for gnu compatibility we allow it here - force_suffix_reset = true; - } + self.append_style_code_for_style(new_style, &mut style_code, &mut force_suffix_reset); } // we need this clear to eol code in some terminals, for instance if the @@ -122,6 +105,58 @@ impl<'a> StyleManager<'a> { ret } + fn raw_indicator_style_for_path(&self, path: &PathData) -> Option { + let indicator = self.indicator_for_raw_code(path)?; + let should_skip = indicator == Indicator::SymbolicLink + && self.ln_color_from_target + && path.path().exists(); + + if should_skip { + return None; + } + + let raw = self.indicator_codes.get(&indicator)?; + if raw.is_empty() { + Some(RawIndicatorStyle::Empty) + } else { + Some(RawIndicatorStyle::Code(raw.clone())) + } + } + + fn build_raw_style_code(&mut self, raw: &str) -> String { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); + style_code + } + + fn append_style_code_for_style( + &mut self, + new_style: Option<&Style>, + style_code: &mut String, + force_suffix_reset: &mut bool, + ) { + if let Some(new_style) = new_style { + // we only need to apply a new style if it's not the same as the current + // style for example if normal is the current style and a file with + // normal style is to be printed we could skip printing new color + // codes + if !self.is_current_style(new_style) { + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(&self.get_style_code(new_style)); + } + } + // if new style is None and current style is Normal we should reset it + else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) + { + style_code.push_str(self.reset(false)); + // even though this is an unnecessary reset for gnu compatibility we allow it here + *force_suffix_reset = true; + } + } + /// Resets the current style and returns the default ANSI reset code to /// reset all text formatting attributes. If `force` is true, the reset is /// done even if the reset has been applied before. @@ -201,13 +236,7 @@ impl<'a> StyleManager<'a> { return self.apply_empty_style(name, wrap); } - let mut style_code = String::new(); - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(ANSI_CSI); - style_code.push_str(&raw); - style_code.push_str(ANSI_SGR_END); - - let mut ret: OsString = style_code.into(); + let mut ret: OsString = self.build_raw_style_code(&raw).into(); ret.push(name); ret.push(self.reset(true)); if wrap { @@ -474,10 +503,188 @@ pub(crate) fn color_name( style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) } +#[derive(Debug)] +pub(crate) enum LsColorsParseError { + UnrecognizedPrefix(String), + InvalidSyntax, +} + +pub(crate) fn validate_ls_colors_env() -> Result<(), LsColorsParseError> { + let Ok(ls_colors) = env::var("LS_COLORS") else { + return Ok(()); + }; + + if ls_colors.is_empty() { + return Ok(()); + } + + validate_ls_colors(&ls_colors) +} + +fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { + let bytes = ls_colors.as_bytes(); + let mut idx = 0; + + while idx < bytes.len() { + match bytes[idx] { + b':' => { + idx += 1; + } + b'*' => { + idx += 1; + idx = parse_funky_string(bytes, idx, true)?; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + _ => { + if idx + 1 >= bytes.len() { + return Err(LsColorsParseError::InvalidSyntax); + } + let label = [bytes[idx], bytes[idx + 1]]; + idx += 2; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + if !is_valid_ls_colors_prefix(label) { + let prefix = String::from_utf8_lossy(&label).into_owned(); + return Err(LsColorsParseError::UnrecognizedPrefix(prefix)); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + } + } + + Ok(()) +} + +fn parse_funky_string( + bytes: &[u8], + mut idx: usize, + equals_end: bool, +) -> Result { + enum State { + Ground, + Backslash, + Octal(u8), + Hex(u8), + Caret, + } + + let mut state = State::Ground; + loop { + let byte = if idx < bytes.len() { bytes[idx] } else { 0 }; + match state { + State::Ground => match byte { + b':' | 0 => return Ok(idx), + b'=' if equals_end => return Ok(idx), + b'\\' => { + state = State::Backslash; + idx += 1; + } + b'^' => { + state = State::Caret; + idx += 1; + } + _ => idx += 1, + }, + State::Backslash => match byte { + 0 => return Err(LsColorsParseError::InvalidSyntax), + b'0'..=b'7' => { + state = State::Octal(byte - b'0'); + idx += 1; + } + b'x' | b'X' => { + state = State::Hex(0); + idx += 1; + } + b'a' | b'b' | b'e' | b'f' | b'n' | b'r' | b't' | b'v' | b'?' | b'_' => { + state = State::Ground; + idx += 1; + } + _ => { + state = State::Ground; + idx += 1; + } + }, + State::Octal(num) => match byte { + b'0'..=b'7' => { + state = State::Octal(num.wrapping_mul(8).wrapping_add(byte - b'0')); + idx += 1; + } + _ => state = State::Ground, + }, + State::Hex(num) => match byte { + b'0'..=b'9' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'0')); + idx += 1; + } + b'a'..=b'f' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'a' + 10)); + idx += 1; + } + b'A'..=b'F' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'A' + 10)); + idx += 1; + } + _ => state = State::Ground, + }, + State::Caret => match byte { + b'@'..=b'~' | b'?' => { + state = State::Ground; + idx += 1; + } + _ => return Err(LsColorsParseError::InvalidSyntax), + }, + } + } +} + +fn is_valid_ls_colors_prefix(label: [u8; 2]) -> bool { + matches!( + label, + [b'l', b'c'] + | [b'r', b'c'] + | [b'e', b'c'] + | [b'r', b's'] + | [b'n', b'o'] + | [b'f', b'i'] + | [b'd', b'i'] + | [b'l', b'n'] + | [b'p', b'i'] + | [b's', b'o'] + | [b'b', b'd'] + | [b'c', b'd'] + | [b'm', b'i'] + | [b'o', b'r'] + | [b'e', b'x'] + | [b'd', b'o'] + | [b's', b'u'] + | [b's', b'g'] + | [b's', b't'] + | [b'o', b'w'] + | [b't', b'w'] + | [b'c', b'a'] + | [b'm', b'h'] + | [b'c', b'l'] + ) +} + fn parse_indicator_codes() -> (HashMap, bool) { let mut indicator_codes = HashMap::new(); let mut ln_color_from_target = false; + // LS_COLORS validity is checked before enabling color output, so parse + // entries directly here for raw indicator overrides. if let Ok(ls_colors) = env::var("LS_COLORS") { for entry in ls_colors.split(':') { if entry.is_empty() { diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 84f21cc309b..84016a1af1f 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -82,7 +82,7 @@ mod dired; use dired::{DiredOutput, is_dired_arg_present}; mod colors; use crate::options::QUOTING_STYLE; -use colors::{StyleManager, color_name}; +use colors::{LsColorsParseError, StyleManager, color_name, validate_ls_colors_env}; pub mod options { pub mod format { @@ -1151,6 +1151,22 @@ impl Config { locale_quoting = None; } + if needs_color { + if let Err(err) = validate_ls_colors_env() { + if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { + show_warning!( + "{}", + translate!( + "ls-warning-unrecognized-ls-colors-prefix", + "prefix" => prefix.quote() + ) + ); + } + show_warning!("{}", translate!("ls-warning-unparsable-ls-colors")); + needs_color = false; + } + } + let color = if needs_color { Some(LsColors::from_env().unwrap_or_default()) } else { @@ -2105,20 +2121,23 @@ fn show_dir_name( write!(out, ":") } -fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { +fn escape_with_locale(name: &OsStr, config: &Config, fallback: F) -> OsString +where + F: FnOnce(&OsStr, QuotingStyle) -> OsString, +{ if let Some(locale) = config.locale_quoting { locale_quote(name, locale) } else { - locale_aware_escape_dir_name(name, config.quoting_style) + fallback(name, config.quoting_style) } } +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_dir_name) +} + fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { - if let Some(locale) = config.locale_quoting { - locale_quote(name, locale) - } else { - locale_aware_escape_name(name, config.quoting_style) - } + escape_with_locale(name, config, locale_aware_escape_name) } fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { From 036a13d9f57b9bab2fc2ae4323bdc92e9e8205cc Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:04:50 +0900 Subject: [PATCH 7/7] Refactor LS_COLORS handling and comments --- src/uu/ls/src/colors.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index b8c1da96bcd..8eb4b70970b 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -20,7 +20,7 @@ const EMPTY_STYLE: &str = "\x1b[m"; enum RawIndicatorStyle { Empty, - Code(String), + Code(Indicator), } /// We need this struct to be able to store the previous style. @@ -77,8 +77,8 @@ impl<'a> StyleManager<'a> { // bypasses fallbacks, matching GNU ls behavior. return self.apply_empty_style(name, wrap); } - Some(RawIndicatorStyle::Code(raw)) => { - style_code.push_str(&self.build_raw_style_code(&raw)); + Some(RawIndicatorStyle::Code(indicator)) => { + self.append_raw_style_code_for_indicator(indicator, &mut style_code); applied_raw_code = true; self.current_style = None; force_suffix_reset = true; @@ -119,7 +119,25 @@ impl<'a> StyleManager<'a> { if raw.is_empty() { Some(RawIndicatorStyle::Empty) } else { - Some(RawIndicatorStyle::Code(raw.clone())) + Some(RawIndicatorStyle::Code(indicator)) + } + } + + // Append a raw SGR sequence for a validated LS_COLORS indicator. + fn append_raw_style_code_for_indicator( + &mut self, + indicator: Indicator, + style_code: &mut String, + ) { + if !self.indicator_codes.contains_key(&indicator) { + return; + } + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + if let Some(raw) = self.indicator_codes.get(&indicator) { + debug_assert!(!raw.is_empty()); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); } } @@ -521,6 +539,7 @@ pub(crate) fn validate_ls_colors_env() -> Result<(), LsColorsParseError> { validate_ls_colors(&ls_colors) } +// GNU-like parser: ensure LS_COLORS has valid labels and well-formed escapes. fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { let bytes = ls_colors.as_bytes(); let mut idx = 0; @@ -567,6 +586,7 @@ fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { Ok(()) } +// Parse a value with GNU-compatible escape sequences, returning the index of the terminator. fn parse_funky_string( bytes: &[u8], mut idx: usize,