From f0b1029624f8c1d248c01390d53cf7e92b035cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Tue, 22 Sep 2026 23:06:58 +0200 Subject: [PATCH 1/9] quoting: Drop `Quotes` to replace with `ShellQuotes` and `CQuotes` Splitting the `Quotes` enum allows to have a specific handling of quotes for shell and c quoting mechanisms. Especially, this prepares the terrain for locale quoting, which is just c quoting with UTF-8 quote characters. --- src/uu/ls/src/dired.rs | 6 +- src/uu/numfmt/src/options.rs | 4 +- src/uu/stat/src/stat.rs | 4 +- .../lib/features/quoting_style/c_quoter.rs | 57 ++++++++++++---- .../features/quoting_style/escaped_char.rs | 33 ++++----- .../src/lib/features/quoting_style/mod.rs | 43 ++---------- .../features/quoting_style/shell_quoter.rs | 68 +++++++++++-------- 7 files changed, 107 insertions(+), 108 deletions(-) diff --git a/src/uu/ls/src/dired.rs b/src/uu/ls/src/dired.rs index b2b1e878a42..6e88d61408e 100644 --- a/src/uu/ls/src/dired.rs +++ b/src/uu/ls/src/dired.rs @@ -40,7 +40,7 @@ use crate::{Config, LsError}; use std::fmt; use std::io::{BufWriter, Stdout, Write}; use uucore::error::UResult; -use uucore::quoting_style::{Quotes, QuotingStyle}; +use uucore::quoting_style::QuotingStyle; #[derive(Debug, Clone, PartialEq)] pub struct BytePosition { @@ -144,9 +144,7 @@ fn dired_quoting_style_name(config: &Config) -> &'static str { } match config.quoting_style { QuotingStyle::Literal { .. } => "literal", - QuotingStyle::C { - quotes: Quotes::None, - } => "escape", + QuotingStyle::C { quotes: None } => "escape", // `Quotes::Single` is never produced by ls's own option parsing // (only reachable through `locale_quoting`, handled above), but // `c` is the closest canonical name if that ever changes. diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index cecb3917439..946a92d7358 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -11,7 +11,7 @@ use std::ops::Range as ByteRange; use std::str::{CharIndices, FromStr}; use crate::units::Unit; -use uucore::quoting_style::{Quotes, QuotingStyle, locale_aware_escape_name}; +use uucore::quoting_style::{CQuotes, QuotingStyle, locale_aware_escape_name}; use uucore::ranges::Range; use uucore::translate; @@ -33,7 +33,7 @@ fn quote_format(s: &str) -> String { locale_aware_escape_name( OsStr::new(s), QuotingStyle::C { - quotes: Quotes::Single, + quotes: Some(CQuotes::SINGLE), }, ) .to_string_lossy() diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 1f9bad2a313..e4abfb2d66e 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -9,7 +9,7 @@ use std::ops::Range; use uucore::diagnostics::OptionValue; use uucore::error::{UError, UResult, USimpleError}; use uucore::i18n::get_ctype_encoding; -use uucore::quoting_style::{Quotes, QuotingStyle, escape_name}; +use uucore::quoting_style::{CQuotes, QuotingStyle, escape_name}; use uucore::translate; use clap::builder::ValueParser; @@ -257,7 +257,7 @@ fn parse_quoting_style(style: &str) -> Option { "c" | "clocale" => QuotingStyle::C_DOUBLE, "escape" => QuotingStyle::C_NO_QUOTES, "locale" => QuotingStyle::C { - quotes: Quotes::Single, + quotes: Some(CQuotes::SINGLE) }, _ => return None, }) diff --git a/src/uucore/src/lib/features/quoting_style/c_quoter.rs b/src/uucore/src/lib/features/quoting_style/c_quoter.rs index 47a21571985..bc55dfa65fe 100644 --- a/src/uucore/src/lib/features/quoting_style/c_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/c_quoter.rs @@ -3,11 +3,37 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use super::{EscapedChar, Quoter, Quotes}; +use super::{EscapedChar, Quoter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CQuotes { + pub(super) opening: char, + pub(super) closing: char, +} + +impl CQuotes { + pub const SINGLE: Self = Self { + opening: '\'', + closing: '\'', + }; + + pub const DOUBLE: Self = Self { + opening: '"', + closing: '"', + }; + + pub(super) fn opening_as_utf8(self, buf: &mut [u8]) -> &[u8] { + self.opening.encode_utf8(buf).as_bytes() + } + + pub(super) fn closing_as_utf8(self, buf: &mut [u8]) -> &[u8] { + self.closing.encode_utf8(buf).as_bytes() + } +} pub(super) struct CQuoter { - /// The type of quotes to use. - quotes: Quotes, + /// The type of quotes to use, if any. + quotes: Option, dirname: bool, @@ -15,12 +41,12 @@ pub(super) struct CQuoter { } impl CQuoter { - pub fn new(quotes: Quotes, dirname: bool, size_hint: usize) -> Self { + pub(super) fn new(quotes: Option, dirname: bool, size_hint: usize) -> Self { let mut buffer = Vec::with_capacity(size_hint); - match quotes { - Quotes::None => (), - Quotes::Single => buffer.push(b'\''), - Quotes::Double => buffer.push(b'"'), + + if let Some(quotes) = quotes { + let mut quote_buf = [0; 4]; + buffer.extend_from_slice(quotes.opening_as_utf8(&mut quote_buf)); } Self { @@ -46,12 +72,15 @@ impl Quoter for CQuoter { } } - fn finalize(mut self: Box) -> Vec { - match self.quotes { - Quotes::None => (), - Quotes::Single => self.buffer.push(b'\''), - Quotes::Double => self.buffer.push(b'"'), + fn finalize(self: Box) -> Vec { + let Self { + quotes, mut buffer, .. + } = *self; + if let Some(quotes) = quotes { + let mut quote_buf = [0; 4]; + buffer.extend_from_slice(quotes.closing_as_utf8(&mut quote_buf)); } - self.buffer + + buffer } } diff --git a/src/uucore/src/lib/features/quoting_style/escaped_char.rs b/src/uucore/src/lib/features/quoting_style/escaped_char.rs index e9a14ca737a..56040cba5a3 100644 --- a/src/uucore/src/lib/features/quoting_style/escaped_char.rs +++ b/src/uucore/src/lib/features/quoting_style/escaped_char.rs @@ -5,7 +5,8 @@ use std::char::from_digit; -use super::Quotes; +use super::c_quoter::CQuotes; +use super::shell_quoter::ShellQuotes; // PR#6559 : Remove `]{}` from special shell chars. const SPECIAL_SHELL_CHARS: &str = "`$&*()|[;\\'\"<>?! "; @@ -107,21 +108,25 @@ impl EscapeOctal { } impl EscapedChar { - pub fn new_literal(c: char) -> Self { + pub(super) fn new_literal(c: char) -> Self { Self { state: EscapeState::Char(c), } } - pub fn new_octal(b: u8) -> Self { + pub(super) fn new_octal(b: u8) -> Self { Self { state: EscapeState::Octal(EscapeOctal::from_byte(b)), } } - pub fn new_c(c: char, quotes: Quotes, dirname: bool) -> Self { + /// Escape a character for C or locale quoting style. + pub(super) fn new_c(c: char, quotes: Option, dirname: bool) -> Self { use EscapeState::*; let init_state = match c { + // When using locale quoting style, only escape the closing + // character, not the opening one. + c if quotes.is_some_and(|q| q.closing == c) => Backslash(c), '\x07' => Backslash('a'), '\x08' => Backslash('b'), '\t' => Backslash('t'), @@ -130,18 +135,7 @@ impl EscapedChar { '\x0C' => Backslash('f'), '\r' => Backslash('r'), '\\' => Backslash('\\'), - '\'' => match quotes { - Quotes::Single => Backslash('\''), - _ => Char('\''), - }, - '"' => match quotes { - Quotes::Double => Backslash('"'), - _ => Char('"'), - }, - ' ' if !dirname => match quotes { - Quotes::None => Backslash(' '), - _ => Char(' '), - }, + ' ' if !dirname && quotes.is_none() => Backslash(' '), ':' if dirname => Backslash(':'), _ if c.is_control() => Octal(EscapeOctal::from_char(c)), _ => Char(c), @@ -149,7 +143,7 @@ impl EscapedChar { Self { state: init_state } } - pub fn new_shell(c: char, escape: bool, quotes: Quotes) -> Self { + pub(super) fn new_shell(c: char, escape: bool, quotes: ShellQuotes) -> Self { use EscapeState::*; let init_state = match c { _ if !escape && c.is_control() => Char(c), @@ -160,10 +154,7 @@ impl EscapedChar { '\x0B' => Backslash('v'), '\x0C' => Backslash('f'), '\r' => Backslash('r'), - '\'' => match quotes { - Quotes::Single => Backslash('\''), - _ => Char('\''), - }, + '\'' if matches!(quotes, ShellQuotes::Single) => Backslash('\''), _ if c.is_control() => Octal(EscapeOctal::from_char(c)), _ if SPECIAL_SHELL_CHARS.contains(c) => ForceQuote(c), _ => Char(c), diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 73105e75f0b..6ece0e15c76 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -20,6 +20,8 @@ mod c_quoter; mod literal_quoter; mod shell_quoter; +pub use c_quoter::CQuotes; + /// The quoting style to use when escaping a name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QuotingStyle { @@ -41,7 +43,7 @@ pub enum QuotingStyle { /// Used in, e.g., `ls --quote-name`. C { /// The type of quotes to use. - quotes: Quotes, + quotes: Option, }, /// Do not escape the string. @@ -78,12 +80,10 @@ impl QuotingStyle { show_control: false, }; - pub const C_NO_QUOTES: Self = Self::C { - quotes: Quotes::None, - }; + pub const C_NO_QUOTES: Self = Self::C { quotes: None }; pub const C_DOUBLE: Self = Self::C { - quotes: Quotes::Double, + quotes: Some(CQuotes::DOUBLE), }; /// Set the `show_control` field of the quoting style. @@ -126,20 +126,6 @@ trait Quoter { fn finalize(self: Box) -> Vec; } -/// The type of quotes to use when escaping a name as a C string. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Quotes { - /// Do not use quotes. - None, - - /// Use single quotes. - Single, - - /// Use double quotes. - Double, - // TODO: Locale -} - /// Escape a name according to the given quoting style. /// /// This inner function provides an additional flag `dirname` which @@ -264,21 +250,11 @@ impl fmt::Display for QuotingStyle { } } -impl fmt::Display for Quotes { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::None => f.write_str("None"), - Self::Single => f.write_str("Single"), - Self::Double => f.write_str("Double"), - } - } -} - #[cfg(test)] mod tests { use crate::{ i18n::UEncoding, - quoting_style::{Quotes, QuotingStyle, escape_name_inner}, + quoting_style::{QuotingStyle, escape_name_inner}, }; // spell-checker:ignore (tests/words) one\'two one'two @@ -1095,11 +1071,4 @@ mod tests { }; assert_eq!(format!("{style}"), "literal"); } - - #[test] - fn test_quotes_display() { - assert_eq!(format!("{}", Quotes::None), "None"); - assert_eq!(format!("{}", Quotes::Single), "Single"); - assert_eq!(format!("{}", Quotes::Double), "Double"); - } } diff --git a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs index 29b1db3bac3..441f1e54042 100644 --- a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs @@ -3,14 +3,30 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use super::{EscapeState, EscapedChar, Quoter, Quotes}; use crate::i18n::UEncoding; +use super::{EscapeState, EscapedChar, Quoter}; + // These are characters with special meaning in the shell (e.g. bash). The // first const contains characters that only have a special meaning when they // appear at the beginning of a name. const SPECIAL_SHELL_CHARS_START: &[u8] = b"~#"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ShellQuotes { + Single, + Double, +} + +impl ShellQuotes { + pub fn as_u8(self) -> u8 { + match self { + Self::Single => b'\'', + Self::Double => b'"', + } + } +} + // Escaped and NonEscaped shell quoting strategies are very different. // Therefore, we are using separate Quoter structures for each of them. @@ -20,7 +36,7 @@ pub(super) struct NonEscapedShellQuoter<'a> { reference: &'a [u8], /// The quotes to be used if necessary - quotes: Quotes, + quotes: ShellQuotes, /// Whether to show control and non-unicode characters, or replace them /// with `?`. @@ -95,7 +111,7 @@ pub(super) struct EscapedShellQuoter<'a> { reference: &'a [u8], /// The quotes to be used if necessary - quotes: Quotes, + quotes: ShellQuotes, // INTERNAL STATE /// Whether the name should be quoted. @@ -117,13 +133,13 @@ impl<'a> EscapedShellQuoter<'a> { ) -> Self { let (mut quotes, must_quote) = initial_quoting(reference, dirname, always_quote, true); // Dollar-quoted byte escapes cannot be nested in double quotes. - if quotes == Quotes::Double { + if quotes == ShellQuotes::Double { let invalid = match encoding { UEncoding::Ascii => !reference.is_ascii(), UEncoding::Utf8 => std::str::from_utf8(reference).is_err(), }; if invalid { - quotes = Quotes::Single; + quotes = ShellQuotes::Single; } } Self { @@ -207,19 +223,19 @@ fn initial_quoting( dirname: bool, always_quote: bool, check_control_chars: bool, -) -> (Quotes, bool) { +) -> (ShellQuotes, bool) { let has_special_chars = input.iter().any(|c| { shell_escaped_char_set(dirname).contains(c) || (check_control_chars && c.is_ascii_control()) }); if has_special_chars { - (Quotes::Single, true) + (ShellQuotes::Single, true) } else if input.contains(&b'\'') { - (Quotes::Double, true) + (ShellQuotes::Double, true) } else if always_quote || input.is_empty() { - (Quotes::Single, true) + (ShellQuotes::Single, true) } else { - (Quotes::Single, false) + (ShellQuotes::Single, false) } } @@ -243,17 +259,13 @@ fn finalize_shell_quoter( buffer: Vec, reference: &[u8], must_quote: bool, - quotes: Quotes, + quotes: ShellQuotes, ) -> Vec { let contains_quote_chars = must_quote || bytes_start_with(reference, SPECIAL_SHELL_CHARS_START); - if must_quote | contains_quote_chars && quotes != Quotes::None { + if must_quote | contains_quote_chars { let mut quoted = Vec::::with_capacity(buffer.len() + 2); - let quote = if quotes == Quotes::Single { - b'\'' - } else { - b'"' - }; + let quote = quotes.as_u8(); quoted.push(quote); quoted.extend(buffer); quoted.push(quote); @@ -272,63 +284,63 @@ mod tests { // Control chars (0-31 and 0x7F) force single quotes in escape mode assert_eq!( initial_quoting(b"\x01", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Control + quote uses single quotes (segmented) in escape mode assert_eq!( initial_quoting(b"\x01'\x01", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Simple quote uses double quotes in escape mode assert_eq!( initial_quoting(b"a'b", false, false, true), - (Quotes::Double, true) + (ShellQuotes::Double, true) ); // Shell special chars force single quotes in escape mode assert_eq!( initial_quoting(b"test$var", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); assert_eq!( initial_quoting(b"test\nline", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Empty string forces quotes in escape mode assert_eq!( initial_quoting(b"", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Always quote flag works in escape mode assert_eq!( initial_quoting(b"normal", false, true, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Normal text doesn't need quoting in escape mode assert_eq!( initial_quoting(b"hello", false, false, true), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); // Dirname affects colon handling in escape mode assert_eq!( initial_quoting(b"dir:name", true, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); assert_eq!( initial_quoting(b"file:name", false, false, true), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); // Control chars ignored in non-escape mode assert_eq!( initial_quoting(b"\x01", false, false, false), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); } } From 6d2946119fe3a7b2aaf0276605f9d2d3724b5e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Mon, 28 Sep 2026 13:25:02 +0200 Subject: [PATCH 2/9] quoting: Fix QuotingStyle fmt::Display --- .../src/lib/features/quoting_style/mod.rs | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 6ece0e15c76..1adf06e7215 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -106,6 +106,27 @@ impl QuotingStyle { } } +impl fmt::Display for QuotingStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Shell { + escape, + always_quote, + .. + } => { + write!( + f, + "shell{}{}", + if escape { "-escape" } else { "" }, + if always_quote { "-always" } else { "" } + ) + } + Self::C { .. } => f.write_str("c"), + Self::Literal { .. } => f.write_str("literal"), + } + } +} + /// Common interface of quoting mechanisms. trait Quoter { /// Push a valid character. @@ -224,32 +245,6 @@ pub fn locale_aware_escape_dir_name(name: &OsStr, style: QuotingStyle) -> OsStri escape_dir_name(name, style, i18n::get_locale_encoding()) } -impl fmt::Display for QuotingStyle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::Shell { - escape, - always_quote, - show_control, - } => { - let mut style = "shell".to_string(); - if escape { - style.push_str("-escape"); - } - if always_quote { - style.push_str("-always-quote"); - } - if show_control { - style.push_str("-show-control"); - } - f.write_str(&style) - } - Self::C { .. } => f.write_str("C"), - Self::Literal { .. } => f.write_str("literal"), - } - } -} - #[cfg(test)] mod tests { use crate::{ @@ -1057,14 +1052,14 @@ mod tests { let style = QuotingStyle::SHELL_ESCAPE; assert_eq!(format!("{style}"), "shell-escape"); - let style = QuotingStyle::SHELL_QUOTE; - assert_eq!(format!("{style}"), "shell-always-quote"); + let style = QuotingStyle::SHELL_ALWAYS; + assert_eq!(format!("{style}"), "shell-always"); let style = QuotingStyle::SHELL.show_control(true); - assert_eq!(format!("{style}"), "shell-show-control"); + assert_eq!(format!("{style}"), "shell"); let style = QuotingStyle::C_DOUBLE; - assert_eq!(format!("{style}"), "C"); + assert_eq!(format!("{style}"), "c"); let style = QuotingStyle::Literal { show_control: false, From 0646450c1c60f0a60eb13471df50c9478946e6e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Fri, 25 Sep 2026 11:31:23 +0200 Subject: [PATCH 3/9] quoting: Rework the `QuotingStyle` struct - Replace C_NO_QUOTES with a new variant Escape - Rename QuotingStyle constants --- src/uu/ls/src/config.rs | 10 +-- src/uu/ls/src/display.rs | 3 +- src/uu/numfmt/src/options.rs | 13 +-- src/uu/rm/src/rm.rs | 2 +- src/uu/stat/src/stat.rs | 18 ++--- src/uu/wc/src/wc.rs | 2 +- .../src/lib/features/format/argument.rs | 2 +- .../src/lib/features/quoting_style/mod.rs | 81 ++++++++++++++----- 8 files changed, 83 insertions(+), 48 deletions(-) diff --git a/src/uu/ls/src/config.rs b/src/uu/ls/src/config.rs index 810d701aec7..cf84d8a4587 100644 --- a/src/uu/ls/src/config.rs +++ b/src/uu/ls/src/config.rs @@ -539,11 +539,11 @@ fn match_quoting_style_name( show_control: false, }), "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), - "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), + "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ALWAYS), "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), + "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_ALWAYS), "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), - "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), + "escape" => QuotingStyleSpec::new(QuotingStyle::Escape), "locale" => QuotingStyleSpec { style: QuotingStyle::Literal { show_control: false, @@ -591,7 +591,7 @@ fn extract_quoting_style( } else if options.get_flag(options::quoting::LITERAL) { (QuotingStyle::Literal { show_control }, None) } else if options.get_flag(options::quoting::ESCAPE) { - (QuotingStyle::C_NO_QUOTES, None) + (QuotingStyle::Escape, None) } else if options.get_flag(options::quoting::C) { (QuotingStyle::C_DOUBLE, None) } else { @@ -614,7 +614,7 @@ fn extract_quoting_style( } match mode { - ProgramMode::Dir | ProgramMode::Vdir => (QuotingStyle::C_NO_QUOTES, None), + ProgramMode::Dir | ProgramMode::Vdir => (QuotingStyle::Escape, None), ProgramMode::Ls if stdout().is_terminal() => { (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) } diff --git a/src/uu/ls/src/display.rs b/src/uu/ls/src/display.rs index d43bb2aad84..4457e4b765e 100644 --- a/src/uu/ls/src/display.rs +++ b/src/uu/ls/src/display.rs @@ -219,8 +219,7 @@ fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { }); let escaped = - if comma_separated && config.locale_quoting.is_none() && style == QuotingStyle::C_NO_QUOTES - { + if comma_separated && config.locale_quoting.is_none() && style == QuotingStyle::Escape { escaped.to_string_lossy().replace(',', "\\,").into() } else { escaped diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index 946a92d7358..871bd250b38 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -11,7 +11,7 @@ use std::ops::Range as ByteRange; use std::str::{CharIndices, FromStr}; use crate::units::Unit; -use uucore::quoting_style::{CQuotes, QuotingStyle, locale_aware_escape_name}; +use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; use uucore::ranges::Range; use uucore::translate; @@ -30,14 +30,9 @@ fn at(iter: &mut Peekable>, s: &str) -> ByteRange { /// control characters written as C escapes. Without this a format holding a /// newline or a tab would split the message across lines or move the cursor. fn quote_format(s: &str) -> String { - locale_aware_escape_name( - OsStr::new(s), - QuotingStyle::C { - quotes: Some(CQuotes::SINGLE), - }, - ) - .to_string_lossy() - .into_owned() + locale_aware_escape_name(OsStr::new(s), QuotingStyle::C_SINGLE) + .to_string_lossy() + .into_owned() } pub const DEBUG: &str = "debug"; diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index d606b300de8..63e23f30f62 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -343,7 +343,7 @@ fn handle_parse_error(e: clap::Error, args: &[OsString]) -> Box { // The path is shell-escaped (quoted only if needed), the file name is // always quoted, matching GNU's two quoting styles. let path = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE); - let quoted = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE_QUOTE); + let quoted = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE_ALWAYS); let _ = writeln!( io::stderr(), "{}", diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index e4abfb2d66e..0a82792d025 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -9,7 +9,7 @@ use std::ops::Range; use uucore::diagnostics::OptionValue; use uucore::error::{UError, UResult, USimpleError}; use uucore::i18n::get_ctype_encoding; -use uucore::quoting_style::{CQuotes, QuotingStyle, escape_name}; +use uucore::quoting_style::{QuotingStyle, escape_name}; use uucore::translate; use clap::builder::ValueParser; @@ -251,14 +251,12 @@ fn parse_quoting_style(style: &str) -> Option { Some(match style { "literal" => QuotingStyle::Literal { show_control: true }, "shell" => QuotingStyle::SHELL.show_control(true), - "shell-always" => QuotingStyle::SHELL_QUOTE.show_control(true), + "shell-always" => QuotingStyle::SHELL_ALWAYS.show_control(true), "shell-escape" => QuotingStyle::SHELL_ESCAPE, - "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_QUOTE, + "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_ALWAYS, "c" | "clocale" => QuotingStyle::C_DOUBLE, - "escape" => QuotingStyle::C_NO_QUOTES, - "locale" => QuotingStyle::C { - quotes: Some(CQuotes::SINGLE) - }, + "escape" => QuotingStyle::Escape, + "locale" => QuotingStyle::C_SINGLE, _ => return None, }) } @@ -1337,7 +1335,7 @@ impl Stater { fn do_stat(&self, file: &OsStr, stdin_is_fifo: bool) -> UResult { let display_name = file.to_string_lossy(); - let quoted_name = || quote_file_name(file, QuotingStyle::SHELL_ESCAPE_QUOTE); + let quoted_name = || quote_file_name(file, QuotingStyle::SHELL_ESCAPE_ALWAYS); let file = if cfg!(unix) && display_name == "-" { if self.show_fs { show_error!("{}", StatError::StdinFilesystemMode); @@ -1788,13 +1786,13 @@ mod tests { fn test_quote_file_name() { let file_name = "nice' file"; assert_eq!( - quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_QUOTE), + quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_ALWAYS), "\"nice' file\"" ); let file_name = "nice\" file"; assert_eq!( - quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_QUOTE), + quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_ALWAYS), "\'nice\" file\'" ); } diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 64d39074843..965beaac913 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -778,7 +778,7 @@ fn files0_iter_file<'a>(path: &Path) -> UResult quoting_style::locale_aware_escape_name( path.as_os_str(), - QuotingStyle::SHELL_ESCAPE_QUOTE, + QuotingStyle::SHELL_ESCAPE_ALWAYS, ) .into_string() .expect("All escaped names with the escaping option return valid strings.") diff --git a/src/uucore/src/lib/features/format/argument.rs b/src/uucore/src/lib/features/format/argument.rs index 96a843165dc..449735245cf 100644 --- a/src/uucore/src/lib/features/format/argument.rs +++ b/src/uucore/src/lib/features/format/argument.rs @@ -208,7 +208,7 @@ fn extract_value( match p { Ok(v) => v, Err(e) => { - let input = locale_aware_escape_name(OsStr::new(input), QuotingStyle::C_NO_QUOTES); + let input = locale_aware_escape_name(OsStr::new(input), QuotingStyle::Escape); match e { ExtendedParserError::Overflow(v) | ExtendedParserError::Underflow(v) => { set_exit_code(1); diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 1adf06e7215..58b6aa5cc4f 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -25,6 +25,13 @@ pub use c_quoter::CQuotes; /// The quoting style to use when escaping a name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QuotingStyle { + /// Do not escape the string. + /// Used in, e.g., `ls --literal`. + Literal { + /// Whether to show control and non-unicode characters, or replace them with `?`. + show_control: bool, + }, + /// Escape the name as a shell string. /// Used in, e.g., `ls --quoting-style=shell`. Shell { @@ -42,20 +49,19 @@ pub enum QuotingStyle { /// Escape the name as a C string. /// Used in, e.g., `ls --quote-name`. C { - /// The type of quotes to use. - quotes: Option, + quotes: CQuotes, }, - /// Do not escape the string. - /// Used in, e.g., `ls --literal`. - Literal { - /// Whether to show control and non-unicode characters, or replace them with `?`. - show_control: bool, - }, + /// Corresponds to --quoting-style=escape + Escape, } /// Provide sane defaults for quoting styles. impl QuotingStyle { + pub const LITERAL: Self = Self::Literal { + show_control: false, + }; + pub const SHELL: Self = Self::Shell { escape: false, always_quote: false, @@ -68,22 +74,24 @@ impl QuotingStyle { show_control: false, }; - pub const SHELL_QUOTE: Self = Self::Shell { + pub const SHELL_ALWAYS: Self = Self::Shell { escape: false, always_quote: true, show_control: false, }; - pub const SHELL_ESCAPE_QUOTE: Self = Self::Shell { + pub const SHELL_ESCAPE_ALWAYS: Self = Self::Shell { escape: true, always_quote: true, show_control: false, }; - pub const C_NO_QUOTES: Self = Self::C { quotes: None }; - pub const C_DOUBLE: Self = Self::C { - quotes: Some(CQuotes::DOUBLE), + quotes: CQuotes::DOUBLE, + }; + + pub const C_SINGLE: Self = Self::C { + quotes: CQuotes::SINGLE, }; /// Set the `show_control` field of the quoting style. @@ -101,9 +109,39 @@ impl QuotingStyle { show_control, }, Literal { .. } => Literal { show_control }, - C { .. } => self, + C { .. } | Escape => self, } } + + /// Set the `always_quote` field of the quoting style. + /// Note: this is a no-op for all variants except `Shell`. + pub fn always_quote(self, always_quote: bool) -> Self { + match self { + Self::Shell { + escape, + show_control, + .. + } => Self::Shell { + escape, + show_control, + always_quote, + }, + _ => self, + } + } + + pub fn parse(s: &str) -> Option { + Some(match s { + "literal" => Self::LITERAL, + "shell" => Self::SHELL, + "shell-always" => Self::SHELL_ALWAYS, + "shell-escape" => Self::SHELL_ESCAPE, + "shell-escape-always" => Self::SHELL_ESCAPE_ALWAYS, + "c" => Self::C_DOUBLE, + "escape" => Self::Escape, + _ => return None, + }) + } } impl fmt::Display for QuotingStyle { @@ -122,6 +160,7 @@ impl fmt::Display for QuotingStyle { ) } Self::C { .. } => f.write_str("c"), + Self::Escape => f.write_str("escape"), Self::Literal { .. } => f.write_str("literal"), } } @@ -164,7 +203,8 @@ fn escape_name_inner( let mut quoter: Box = match style { QuotingStyle::Literal { .. } => Box::new(LiteralQuoter::new(name.len())), - QuotingStyle::C { quotes } => Box::new(CQuoter::new(quotes, dirname, name.len())), + QuotingStyle::C { quotes } => Box::new(CQuoter::new(Some(quotes), dirname, name.len())), + QuotingStyle::Escape => Box::new(CQuoter::new(None, dirname, name.len())), QuotingStyle::Shell { escape: true, always_quote, @@ -260,14 +300,14 @@ mod tests { show_control: false, }, "literal-show" => QuotingStyle::Literal { show_control: true }, - "escape" => QuotingStyle::C_NO_QUOTES, + "escape" => QuotingStyle::Escape, "c" => QuotingStyle::C_DOUBLE, "shell" => QuotingStyle::SHELL, "shell-show" => QuotingStyle::SHELL.show_control(true), - "shell-always" => QuotingStyle::SHELL_QUOTE, - "shell-always-show" => QuotingStyle::SHELL_QUOTE.show_control(true), + "shell-always" => QuotingStyle::SHELL_ALWAYS, + "shell-always-show" => QuotingStyle::SHELL_ALWAYS.show_control(true), "shell-escape" => QuotingStyle::SHELL_ESCAPE, - "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_QUOTE, + "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_ALWAYS, _ => panic!("Invalid name!"), } } @@ -1061,6 +1101,9 @@ mod tests { let style = QuotingStyle::C_DOUBLE; assert_eq!(format!("{style}"), "c"); + let style = QuotingStyle::Escape; + assert_eq!(format!("{style}"), "escape"); + let style = QuotingStyle::Literal { show_control: false, }; From a784330880424f533ac2982e4f1ed58718cca2bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Thu, 24 Sep 2026 14:22:42 +0200 Subject: [PATCH 4/9] quoting: Introduce Locale and CLocale QuotingStyles --- src/uucore/locales/errors/en-US.ftl | 3 + src/uucore/locales/errors/fr-FR.ftl | 3 + .../lib/features/quoting_style/c_quoter.rs | 5 + .../src/lib/features/quoting_style/mod.rs | 184 ++++++++++++++---- 4 files changed, 157 insertions(+), 38 deletions(-) diff --git a/src/uucore/locales/errors/en-US.ftl b/src/uucore/locales/errors/en-US.ftl index 1f1b9230a26..5720f768ecd 100644 --- a/src/uucore/locales/errors/en-US.ftl +++ b/src/uucore/locales/errors/en-US.ftl @@ -53,3 +53,6 @@ size-diag-help-syntax = a size is a number and an optional unit: K, M, G and so # in its own words. range-diag-label-too-large = this number is too large range-diag-label-inverted = this range ends before it starts + +# Invalid QUOTING_STYLE env var +invalid-quoting-style-env-var = ignoring invalid value of environment variable QUOTING_STYLE: { $invalid } diff --git a/src/uucore/locales/errors/fr-FR.ftl b/src/uucore/locales/errors/fr-FR.ftl index 57dc56a6a93..28642d75ea7 100644 --- a/src/uucore/locales/errors/fr-FR.ftl +++ b/src/uucore/locales/errors/fr-FR.ftl @@ -53,3 +53,6 @@ size-diag-help-syntax = une taille est un nombre suivi d'une unité facultative # compte, donc chaque utilitaire le dit avec ses propres mots. range-diag-label-too-large = ce nombre est trop grand range-diag-label-inverted = cet intervalle se termine avant de commencer + +# variable d'environnement QUOTING_STYLE invalide +invalid-quoting-style-env-var = valeur invalide de la variable d'environnement QUOTING_STYLE ignorée : { $invalid } diff --git a/src/uucore/src/lib/features/quoting_style/c_quoter.rs b/src/uucore/src/lib/features/quoting_style/c_quoter.rs index bc55dfa65fe..1083ca59835 100644 --- a/src/uucore/src/lib/features/quoting_style/c_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/c_quoter.rs @@ -22,6 +22,11 @@ impl CQuotes { closing: '"', }; + pub const LOCALE_UTF8: Self = Self { + opening: '\u{2018}', + closing: '\u{2019}', + }; + pub(super) fn opening_as_utf8(self, buf: &mut [u8]) -> &[u8] { self.opening.encode_utf8(buf).as_bytes() } diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 58b6aa5cc4f..95b994953e4 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -8,10 +8,13 @@ use std::ffi::{OsStr, OsString}; use std::fmt; +use os_display::Quotable; + use crate::i18n::{self, UEncoding}; use crate::quoting_style::c_quoter::CQuoter; use crate::quoting_style::literal_quoter::LiteralQuoter; use crate::quoting_style::shell_quoter::{EscapedShellQuoter, NonEscapedShellQuoter}; +use crate::{show_error, translate}; mod escaped_char; pub use escaped_char::{EscapeState, EscapedChar}; @@ -54,6 +57,10 @@ pub enum QuotingStyle { /// Corresponds to --quoting-style=escape Escape, + + /// Escape using locale. + Locale, + CLocale, } /// Provide sane defaults for quoting styles. @@ -109,7 +116,7 @@ impl QuotingStyle { show_control, }, Literal { .. } => Literal { show_control }, - C { .. } | Escape => self, + C { .. } | Escape | Locale | CLocale => self, } } @@ -139,6 +146,8 @@ impl QuotingStyle { "shell-escape-always" => Self::SHELL_ESCAPE_ALWAYS, "c" => Self::C_DOUBLE, "escape" => Self::Escape, + "locale" => Self::Locale, + "clocale" => Self::CLocale, _ => return None, }) } @@ -162,10 +171,28 @@ impl fmt::Display for QuotingStyle { Self::C { .. } => f.write_str("c"), Self::Escape => f.write_str("escape"), Self::Literal { .. } => f.write_str("literal"), + Self::Locale => f.write_str("locale"), + Self::CLocale => f.write_str("clocale"), } } } +pub fn quoting_style_from_env() -> Option { + // If variable is not set, return None quietly. + let style = std::env::var_os("QUOTING_STYLE")?; + + let Some(qs) = style.to_str().and_then(QuotingStyle::parse) else { + // Variable is present but invalid, report an error. + show_error!( + "{}", + translate!("invalid-quoting-style-env-var", "invalid" => style.to_string_lossy().quote()) + ); + return None; + }; + + Some(qs) +} + /// Common interface of quoting mechanisms. trait Quoter { /// Push a valid character. @@ -205,6 +232,20 @@ fn escape_name_inner( QuotingStyle::Literal { .. } => Box::new(LiteralQuoter::new(name.len())), QuotingStyle::C { quotes } => Box::new(CQuoter::new(Some(quotes), dirname, name.len())), QuotingStyle::Escape => Box::new(CQuoter::new(None, dirname, name.len())), + QuotingStyle::CLocale => { + let quotes = match encoding { + UEncoding::Ascii => CQuotes::DOUBLE, + UEncoding::Utf8 => CQuotes::LOCALE_UTF8, + }; + Box::new(CQuoter::new(Some(quotes), dirname, name.len())) + } + QuotingStyle::Locale => { + let quotes = match encoding { + UEncoding::Ascii => CQuotes::SINGLE, + UEncoding::Utf8 => CQuotes::LOCALE_UTF8, + }; + Box::new(CQuoter::new(Some(quotes), dirname, name.len())) + } QuotingStyle::Shell { escape: true, always_quote, @@ -294,41 +335,26 @@ mod tests { // spell-checker:ignore (tests/words) one\'two one'two + // Custom quoting style parsing for testing purposes (a -show might be appended) fn get_style(s: &str) -> QuotingStyle { - match s { - "literal" => QuotingStyle::Literal { - show_control: false, - }, - "literal-show" => QuotingStyle::Literal { show_control: true }, - "escape" => QuotingStyle::Escape, - "c" => QuotingStyle::C_DOUBLE, - "shell" => QuotingStyle::SHELL, - "shell-show" => QuotingStyle::SHELL.show_control(true), - "shell-always" => QuotingStyle::SHELL_ALWAYS, - "shell-always-show" => QuotingStyle::SHELL_ALWAYS.show_control(true), - "shell-escape" => QuotingStyle::SHELL_ESCAPE, - "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_ALWAYS, - _ => panic!("Invalid name!"), - } - } - - fn check_names_inner(encoding: UEncoding, name: &[u8], map: &[(T, &str)]) -> Vec> { - map.iter() - .map(|(_, style)| escape_name_inner(name, get_style(style), false, encoding)) - .collect() + let show_control = s.ends_with("-show"); + let s = s.trim_end_matches("-show"); + QuotingStyle::parse(s).unwrap().show_control(show_control) } fn check_names_encoding(encoding: UEncoding, name: &str, map: &[(&str, &str)]) { - assert_eq!( - map.iter() - .map(|(correct, _)| *correct) - .collect::>(), - check_names_inner(encoding, name.as_bytes(), map) - .iter() - .map(|bytes| std::str::from_utf8(bytes) - .expect("valid str goes in, valid str comes out")) - .collect::>() - ); + for (expected, style) in map { + assert_eq!( + *expected, + std::str::from_utf8(&escape_name_inner( + name.as_bytes(), + get_style(style), + false, + encoding + )) + .expect("valid str goes in, valid str comes out") + ); + } } fn check_names_both(name: &str, map: &[(&str, &str)]) { @@ -337,12 +363,12 @@ mod tests { } fn check_names_encoding_raw(encoding: UEncoding, name: &[u8], map: &[(&[u8], &str)]) { - assert_eq!( - map.iter() - .map(|(correct, _)| *correct) - .collect::>(), - check_names_inner(encoding, name, map) - ); + for (expected, style) in map { + assert_eq!( + *expected, + escape_name_inner(name, get_style(style), false, encoding) + ); + } } fn check_names_raw_both(name: &[u8], map: &[(&[u8], &str)]) { @@ -1087,6 +1113,82 @@ mod tests { ); } + #[test] + fn test_locale_styles() { + fn test_case(name: &str, utf8: &str, ascii_locale: &str, ascii_clocale: &str) { + check_names_encoding( + UEncoding::Utf8, + name, + &[(utf8, "locale"), (utf8, "clocale")], + ); + check_names_encoding( + UEncoding::Ascii, + name, + &[(ascii_locale, "locale"), (ascii_clocale, "clocale")], + ); + } + fn test_case_raw(name: &[u8], utf8: &[u8], ascii_locale: &[u8], ascii_clocale: &[u8]) { + check_names_encoding_raw( + UEncoding::Utf8, + name, + &[(utf8, "locale"), (utf8, "clocale")], + ); + check_names_encoding_raw( + UEncoding::Ascii, + name, + &[(ascii_locale, "locale"), (ascii_clocale, "clocale")], + ); + } + + // Simple name + + test_case( + "one_two", + "\u{2018}one_two\u{2019}", + "'one_two'", + "\"one_two\"", + ); + + // Space + + test_case(" ", "\u{2018} \u{2019}", "' '", "\" \""); + + // Quotes + + test_case("'", "\u{2018}'\u{2019}", "'\\''", "\"'\""); + test_case("\"", "\u{2018}\"\u{2019}", "'\"'", "\"\\\"\""); + test_case( + "\u{2018}", + "\u{2018}\u{2018}\u{2019}", // opening quote is not escaped + "'\\342\\200\\230'", + "\"\\342\\200\\230\"", + ); + test_case( + "\u{2019}", + "\u{2018}\\\u{2019}\u{2019}", // closing quote is escaped + "'\\342\\200\\231'", + "\"\\342\\200\\231\"", + ); + + // ASCII control + + test_case( + "\x00\n", + "\u{2018}\\000\\n\u{2019}", + "'\\000\\n'", + "\"\\000\\n\"", + ); + + // Non-Unicode + + test_case_raw( + b"\xEF", + "\u{2018}\\357\u{2019}".as_bytes(), + b"'\\357'", + b"\"\\357\"", + ); + } + #[test] fn test_quoting_style_display() { let style = QuotingStyle::SHELL_ESCAPE; @@ -1108,5 +1210,11 @@ mod tests { show_control: false, }; assert_eq!(format!("{style}"), "literal"); + + let style = QuotingStyle::Locale; + assert_eq!(format!("{style}"), "locale"); + + let style = QuotingStyle::CLocale; + assert_eq!(format!("{style}"), "clocale"); } } From 09def50e91a1adf428715b33268560e5f3aca262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Fri, 25 Sep 2026 13:07:10 +0200 Subject: [PATCH 5/9] quoting: Improve documentation --- .../src/lib/features/quoting_style/mod.rs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 95b994953e4..1ee9e5b6192 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -25,13 +25,13 @@ mod shell_quoter; pub use c_quoter::CQuotes; -/// The quoting style to use when escaping a name. +/// Enum representing a quoting and escaping strategy to print a string. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QuotingStyle { /// Do not escape the string. /// Used in, e.g., `ls --literal`. Literal { - /// Whether to show control and non-unicode characters, or replace them with `?`. + /// If false, replaces control and non-Unicode characters with `?`. show_control: bool, }, @@ -45,7 +45,7 @@ pub enum QuotingStyle { /// Whether to always quote the name. always_quote: bool, - /// Whether to show control and non-unicode characters, or replace them with `?`. + /// If false, replaces control and non-Unicode characters with `?`. show_control: bool, }, @@ -55,7 +55,8 @@ pub enum QuotingStyle { quotes: CQuotes, }, - /// Corresponds to --quoting-style=escape + /// Simply escape characters without quoting + /// Used in, e.g., `ls --quoting-style=escape`. Escape, /// Escape using locale. @@ -102,7 +103,9 @@ impl QuotingStyle { }; /// Set the `show_control` field of the quoting style. - /// Note: this is a no-op for the `C` variant. + /// + /// > This is a no-op for variants others than [`QuotingStyle::Shell`] + /// > and [`QuotingStyle::Literal`]. pub fn show_control(self, show_control: bool) -> Self { use QuotingStyle::*; match self { @@ -121,7 +124,8 @@ impl QuotingStyle { } /// Set the `always_quote` field of the quoting style. - /// Note: this is a no-op for all variants except `Shell`. + /// + /// > This is a no-op for all variants except [`QuotingStyle::Shell`]. pub fn always_quote(self, always_quote: bool) -> Self { match self { Self::Shell { @@ -137,6 +141,10 @@ impl QuotingStyle { } } + /// Parse a [`QuotingStyle`] from a string. + /// + /// Used for e.g., the `QUOTING_STYLE` environment variable and the + /// `--quoting-style` option of `ls`. pub fn parse(s: &str) -> Option { Some(match s { "literal" => Self::LITERAL, @@ -177,6 +185,9 @@ impl fmt::Display for QuotingStyle { } } +/// Retrieve the `QUOTING_STYLE` environment variable. If present, parse it +/// through [`QuotingStyle::parse`], and write a standard error message to +/// stderr in case of an invalid value. pub fn quoting_style_from_env() -> Option { // If variable is not set, return None quietly. let style = std::env::var_os("QUOTING_STYLE")?; @@ -213,7 +224,7 @@ trait Quoter { fn finalize(self: Box) -> Vec; } -/// Escape a name according to the given quoting style. +/// Escape a name according to the given [`QuotingStyle`] and [`UEncoding`]. /// /// This inner function provides an additional flag `dirname` which /// is meant for ls' directory name display. @@ -291,14 +302,15 @@ fn escape_name_inner( quoter.finalize() } -/// Escape a filename with respect to the given style. +/// Escape a filename with respect to the given [`QuotingStyle`]. pub fn escape_name(name: &OsStr, style: QuotingStyle, encoding: UEncoding) -> OsString { let name = crate::os_str_as_bytes_lossy(name); crate::os_string_from_vec(escape_name_inner(&name, style, false, encoding)) .expect("all byte sequences should be valid for platform, or already replaced in name") } -/// Retrieve the encoding from the locale and pass it to `escape_name`. +/// Retrieve the encoding from the locale and pass it to [`escape_name`]. +#[inline(always)] pub fn locale_aware_escape_name(name: &OsStr, style: QuotingStyle) -> OsString { escape_name(name, style, i18n::get_locale_encoding()) } @@ -321,7 +333,7 @@ pub fn escape_dir_name(dir_name: &OsStr, style: QuotingStyle, encoding: UEncodin .expect("all byte sequences should be valid for platform, or already replaced in name") } -/// Retrieve the encoding from the locale and pass it to `escape_dir_name`. +/// Retrieve the encoding from the locale and pass it to [`escape_dir_name`]. pub fn locale_aware_escape_dir_name(name: &OsStr, style: QuotingStyle) -> OsString { escape_dir_name(name, style, i18n::get_locale_encoding()) } From 9902bd4a31841110604fe8fddde91fdb03146909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Thu, 24 Sep 2026 14:28:30 +0200 Subject: [PATCH 6/9] ls: Use common QUOTING_STYLE parsing and remove duplicate code --- src/uu/ls/locales/en-US.ftl | 1 - src/uu/ls/locales/fr-FR.ftl | 1 - src/uu/ls/src/config.rs | 127 +++++++-------------------------- src/uu/ls/src/dired.rs | 32 +-------- src/uu/ls/src/display.rs | 137 ++++-------------------------------- tests/by-util/test_ls.rs | 4 +- 6 files changed, 42 insertions(+), 260 deletions(-) diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index dda2379fa1a..075ec37bf86 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -129,7 +129,6 @@ ls-help-full-time = like -l --time-style=full-iso ls-help-context = print any security context of each file ls-help-group-directories-first = group directories before files; can be augmented with a --sort option, but any use of --sort=none (-U) disables grouping -ls-invalid-quoting-style = {$program}: Ignoring invalid value of environment variable QUOTING_STYLE: '{$style}' 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} diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 1ee9d7dfaac..06a1a6aa6d4 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -127,7 +127,6 @@ ls-help-full-time = comme -l --time-style=full-iso ls-help-context = afficher tout contexte de sécurité de chaque fichier ls-help-group-directories-first = grouper les répertoires avant les fichiers ; peut être augmenté avec une option --sort, mais toute utilisation de --sort=none (-U) désactive le groupement -ls-invalid-quoting-style = {$program} : Ignorer la valeur invalide de la variable d'environnement QUOTING_STYLE : '{$style}' 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} diff --git a/src/uu/ls/src/config.rs b/src/uu/ls/src/config.rs index cf84d8a4587..5fdd4a622cd 100644 --- a/src/uu/ls/src/config.rs +++ b/src/uu/ls/src/config.rs @@ -9,7 +9,7 @@ use std::{ borrow::Cow, ffi::{OsStr, OsString}, - io::{self, IsTerminal, Write as _, stdout}, + io::{IsTerminal, stdout}, num::IntErrorKind, }; @@ -18,16 +18,23 @@ use lscolors::LsColors; use term_grid::SPACES_IN_TAB; use uucore::{ - diagnostics::OptionValue, display::Quotable, error::UResult, format::human::SizeFormat, - fsext::MetadataTimeField, line_ending::LineEnding, parser::parse_block_size, - parser::parse_glob, parser::parse_size::parse_size_non_zero_u64, quoting_style::QuotingStyle, - show_error, show_warning, time::format, translate, + diagnostics::OptionValue, + display::Quotable, + error::UResult, + format::human::SizeFormat, + fsext::MetadataTimeField, + line_ending::LineEnding, + parser::{parse_block_size, parse_glob, parse_size::parse_size_non_zero_u64}, + quoting_style::{QuotingStyle, quoting_style_from_env}, + show_error, show_warning, + time::format, + translate, }; use crate::{ LsError, colors::{LsColorsParseError, validate_ls_colors_env}, - display::{Format, IndicatorStyle, LocaleQuoting, LongFormat}, + display::{Format, IndicatorStyle, LongFormat}, options::QUOTING_STYLE, }; @@ -226,7 +233,6 @@ pub struct Config { // Dir and vdir needs access to this field pub quoting_style: QuotingStyle, pub(crate) show_control_chars: bool, - pub(crate) locale_quoting: Option, pub(crate) indicator_style: Option, pub(crate) time_format_recent: String, // Time format for recent dates pub(crate) time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) @@ -497,73 +503,6 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { } } -/// Match the argument given to --quoting-style or the [`QUOTING_STYLE`] env variable. -/// -/// # Arguments -/// -/// * `style`: the actual argument string -/// * `show_control` - A boolean value representing whether to show control characters. -/// -/// # 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 spec = match style { - "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { - show_control: false, - }), - "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), - "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ALWAYS), - "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_ALWAYS), - "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), - "escape" => QuotingStyleSpec::new(QuotingStyle::Escape), - "locale" => QuotingStyleSpec { - style: QuotingStyle::Literal { - show_control: false, - }, - fixed_control: true, - locale: Some(LocaleQuoting::Single), - }, - "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), - _ => return None, - }; - - 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. /// If no options are given, it looks if a default quoting style is provided /// through the [`QUOTING_STYLE`] environment variable. @@ -580,45 +519,32 @@ fn extract_quoting_style( options: &clap::ArgMatches, show_control: bool, mode: ProgramMode, -) -> (QuotingStyle, Option) { +) -> QuotingStyle { 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(pair) => pair, + match QuotingStyle::parse(style) { + Some(qs) => qs.show_control(show_control), None => unreachable!("Should have been caught by Clap"), } } else if options.get_flag(options::quoting::LITERAL) { - (QuotingStyle::Literal { show_control }, None) + QuotingStyle::Literal { show_control } } else if options.get_flag(options::quoting::ESCAPE) { - (QuotingStyle::Escape, None) + QuotingStyle::Escape } else if options.get_flag(options::quoting::C) { - (QuotingStyle::C_DOUBLE, None) + QuotingStyle::C_DOUBLE } else { // If set, the QUOTING_STYLE environment variable specifies a default style. - // Value may not be valid UTF-8. - if let Some(os_style) = std::env::var_os("QUOTING_STYLE") { - let style = os_style.to_string_lossy(); - if let Some(pair) = match_quoting_style_name(&style, show_control) { - return pair; - } - let _ = writeln!( - io::stderr(), - "{}", - translate!( - "ls-invalid-quoting-style", - "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), - "style" => style - ) - ); + if let Some(qs) = quoting_style_from_env() { + return qs.show_control(show_control); } match mode { - ProgramMode::Dir | ProgramMode::Vdir => (QuotingStyle::Escape, None), + ProgramMode::Dir | ProgramMode::Vdir => QuotingStyle::Escape, ProgramMode::Ls if stdout().is_terminal() => { - (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) + QuotingStyle::SHELL_ESCAPE.show_control(show_control) } - ProgramMode::Ls => (QuotingStyle::Literal { show_control }, None), + ProgramMode::Ls => QuotingStyle::Literal { show_control }, } } } @@ -866,8 +792,7 @@ impl Config { || !stdout().is_terminal() }; - let (mut quoting_style, mut locale_quoting) = - extract_quoting_style(options, show_control, mode); + let mut quoting_style = extract_quoting_style(options, show_control, mode); let indicator_style = extract_indicator_style(options); let mut ignore_patterns: Vec = Vec::new(); @@ -959,7 +884,6 @@ impl Config { if zero_idx > last_of(&zero_quoting_style_opts) { quoting_style = QuotingStyle::Literal { show_control }; - locale_quoting = None; } if needs_color && let Err(err) = validate_ls_colors_env() { @@ -1046,7 +970,6 @@ impl Config { width, quoting_style, show_control_chars: options.get_flag(options::SHOW_CONTROL_CHARS), - locale_quoting, indicator_style, time_format_recent, time_format_older, diff --git a/src/uu/ls/src/dired.rs b/src/uu/ls/src/dired.rs index 6e88d61408e..ce96dbd3b82 100644 --- a/src/uu/ls/src/dired.rs +++ b/src/uu/ls/src/dired.rs @@ -35,12 +35,10 @@ /// Overall, the module ensures each entry in the DIRED output has the correct /// byte position, considering additional lines or padding affecting positions. /// -use crate::display::LocaleQuoting; use crate::{Config, LsError}; use std::fmt; use std::io::{BufWriter, Stdout, Write}; use uucore::error::UResult; -use uucore::quoting_style::QuotingStyle; #[derive(Debug, Clone, PartialEq)] pub struct BytePosition { @@ -128,40 +126,12 @@ pub fn print_dired_output( writeln!( out, "//DIRED-OPTIONS// --quoting-style={}", - dired_quoting_style_name(config) + config.quoting_style ) .map_err(LsError::WriteError)?; Ok(()) } -/// The canonical `--quoting-style` name for the `//DIRED-OPTIONS//` trailer. -/// `QuotingStyle`'s `Display` impl collapses distinct style names together and adds suffixes GNU's trailer never uses, so this maps the config directly instead. -fn dired_quoting_style_name(config: &Config) -> &'static str { - match config.locale_quoting { - Some(LocaleQuoting::Single) => return "locale", - Some(LocaleQuoting::Double) => return "clocale", - None => {} - } - match config.quoting_style { - QuotingStyle::Literal { .. } => "literal", - QuotingStyle::C { quotes: None } => "escape", - // `Quotes::Single` is never produced by ls's own option parsing - // (only reachable through `locale_quoting`, handled above), but - // `c` is the closest canonical name if that ever changes. - QuotingStyle::C { .. } => "c", - QuotingStyle::Shell { - escape, - always_quote, - .. - } => match (escape, always_quote) { - (false, false) => "shell", - (false, true) => "shell-always", - (true, false) => "shell-escape", - (true, true) => "shell-escape-always", - }, - } -} - /// Helper function to print positions with a given prefix. fn print_positions( out: &mut BufWriter, diff --git a/src/uu/ls/src/display.rs b/src/uu/ls/src/display.rs index 4457e4b765e..a6ec8066f48 100644 --- a/src/uu/ls/src/display.rs +++ b/src/uu/ls/src/display.rs @@ -47,7 +47,6 @@ use uucore::{ format::human::human_readable, fs::display_permissions, fsext::metadata_get_time, - i18n::{UEncoding, get_ctype_encoding}, line_ending::LineEnding, os_str_as_bytes_lossy, os_string_from_vec, quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}, @@ -132,12 +131,6 @@ pub(crate) enum IndicatorStyle { Classify, } -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum LocaleQuoting { - Single, - Double, -} - #[derive(PartialEq, Eq, Debug)] pub enum Format { Columns, @@ -170,7 +163,8 @@ pub fn show_dir_name( out: &mut BufWriter, config: &Config, ) -> std::io::Result { - let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); + let escaped_name = + locale_aware_escape_dir_name(path_data.path().as_os_str(), config.quoting_style); let name = if config.hyperlink && !config.dired { create_hyperlink(&escaped_name, path_data) @@ -183,47 +177,24 @@ pub fn show_dir_name( Ok(name.len()) } -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 { - 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 { let comma_separated = config.format == Format::Commas && os_str_as_bytes_lossy(name).contains(&b','); - let style = match config.quoting_style { - QuotingStyle::Shell { - escape, - show_control, - .. - } if comma_separated => QuotingStyle::Shell { - escape, - always_quote: true, - show_control, - }, - style => style, + + // Format::Commas forces quoting. + let style = if comma_separated { + config.quoting_style.always_quote(true) + } else { + config.quoting_style }; - let escaped = escape_with_locale(name, config, |name, _| { - locale_aware_escape_name(name, style) - }); + let escaped = locale_aware_escape_name(name, style); - let escaped = - if comma_separated && config.locale_quoting.is_none() && style == QuotingStyle::Escape { - escaped.to_string_lossy().replace(',', "\\,").into() - } else { - escaped - }; + let escaped = if comma_separated && style == QuotingStyle::Escape { + escaped.to_string_lossy().replace(',', "\\,").into() + } else { + escaped + }; if config.format == Format::Commas && config.line_ending == LineEnding::Newline @@ -245,86 +216,6 @@ fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { escaped } -fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { - let bytes = os_str_as_bytes_lossy(name); - - // In a UTF-8 locale GNU's locale/clocale quoting uses Unicode quotation - // marks U+2018 (LEFT) and U+2019 (RIGHT) as delimiters for both styles, - // keyed off LC_CTYPE. Since the delimiters differ from any ASCII quote, - // embedded apostrophes and double quotes are left untouched; only control - // characters, backslashes and invalid bytes are escaped. - if get_ctype_encoding() == UEncoding::Utf8 { - let mut quoted = String::with_capacity(name.len() + 6); - quoted.push('\u{2018}'); - for chunk in bytes.utf8_chunks() { - for c in chunk.valid().chars() { - if c == '\\' { - quoted.push_str("\\\\"); - } else if c.is_ascii() && c.is_control() { - push_basic_escape(&mut quoted, c as u8); - } else if c.is_control() { - // Non-ASCII control characters (the C1 range, e.g. - // U+0085 NEL) are not printable; octal-escape their - // UTF-8 bytes like GNU does for non-printable chars. - let mut buf = [0u8; 4]; - for &byte in c.encode_utf8(&mut buf).as_bytes() { - let _ = write!(quoted, "\\{byte:03o}"); - } - } else { - quoted.push(c); - } - } - for &byte in chunk.invalid() { - let _ = write!(quoted, "\\{byte:03o}"); - } - } - quoted.push('\u{2019}'); - return OsString::from(quoted); - } - - let mut quoted = String::with_capacity(name.len() + 2); - 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}"); - } - } -} - pub fn should_display(entry: &DirEntry, config: &Config) -> bool { // check if hidden if config.files == Files::Normal && is_hidden(entry) { diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 6ee2b34bf20..d518238a190 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -4338,7 +4338,7 @@ fn test_ls_invalid_quoting_style_env_var_warns() { .arg("alpha") .succeeds() .stdout_is("alpha\n") - .stderr_contains("Ignoring invalid value of environment variable QUOTING_STYLE"); + .stderr_contains("ignoring invalid value of environment variable QUOTING_STYLE"); } #[cfg(unix)] @@ -4356,7 +4356,7 @@ fn test_ls_invalid_quoting_style_env_var_non_utf8() { .arg("alpha") .succeeds() .stdout_is("alpha\n") - .stderr_contains("Ignoring invalid value of environment variable QUOTING_STYLE"); + .stderr_contains("ignoring invalid value of environment variable QUOTING_STYLE"); } #[test] From 5e3d23564dd8957cc4977acc7a63dc8caf026d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Thu, 24 Sep 2026 17:18:54 +0200 Subject: [PATCH 7/9] stat: Remove duplicated code --- src/uu/stat/locales/en-US.ftl | 1 - src/uu/stat/locales/fr-FR.ftl | 1 - src/uu/stat/src/stat.rs | 39 ++++++----------------------------- 3 files changed, 6 insertions(+), 35 deletions(-) diff --git a/src/uu/stat/locales/en-US.ftl b/src/uu/stat/locales/en-US.ftl index f962eaf3db9..0bf0e40c4ab 100644 --- a/src/uu/stat/locales/en-US.ftl +++ b/src/uu/stat/locales/en-US.ftl @@ -56,7 +56,6 @@ stat-after-help = Valid format sequences for files (without `--file-system`): ## Error messages -stat-warning-invalid-env-quoting-style = ignoring invalid value of environment variable QUOTING_STYLE: '{$style}' stat-error-invalid-directive = {$directive}: invalid directive stat-error-cannot-read-filesystem = cannot read table of mounted file systems: {$error} stat-error-stdin-filesystem-mode = using '-' to denote standard input does not work in file system mode diff --git a/src/uu/stat/locales/fr-FR.ftl b/src/uu/stat/locales/fr-FR.ftl index d7f48e82439..2f5c7737e40 100644 --- a/src/uu/stat/locales/fr-FR.ftl +++ b/src/uu/stat/locales/fr-FR.ftl @@ -94,7 +94,6 @@ stat-word-birth = Créé ## Messages d'erreur -stat-warning-invalid-env-quoting-style = valeur invalide de la variable d'environnement QUOTING_STYLE ignorée : '{$style}' stat-error-invalid-directive = {$directive} : directive invalide stat-error-cannot-read-filesystem = impossible de lire la table des systèmes de fichiers montés : {$error} stat-error-stdin-filesystem-mode = utiliser '-' pour désigner l'entrée standard ne fonctionne pas en mode système de fichiers diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 0a82792d025..c030437f656 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -9,7 +9,7 @@ use std::ops::Range; use uucore::diagnostics::OptionValue; use uucore::error::{UError, UResult, USimpleError}; use uucore::i18n::get_ctype_encoding; -use uucore::quoting_style::{QuotingStyle, escape_name}; +use uucore::quoting_style::{QuotingStyle, escape_name, quoting_style_from_env}; use uucore::translate; use clap::builder::ValueParser; @@ -246,21 +246,6 @@ pub enum OutputType<'a> { Unknown, } -/// Match a `QUOTING_STYLE` value to a quoting style. -fn parse_quoting_style(style: &str) -> Option { - Some(match style { - "literal" => QuotingStyle::Literal { show_control: true }, - "shell" => QuotingStyle::SHELL.show_control(true), - "shell-always" => QuotingStyle::SHELL_ALWAYS.show_control(true), - "shell-escape" => QuotingStyle::SHELL_ESCAPE, - "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_ALWAYS, - "c" | "clocale" => QuotingStyle::C_DOUBLE, - "escape" => QuotingStyle::Escape, - "locale" => QuotingStyle::C_SINGLE, - _ => return None, - }) -} - #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum Precision { NotSpecified, @@ -518,22 +503,6 @@ fn quote_file_name(file_name: &OsStr, quoting_style: QuotingStyle) -> String { .to_string() } -/// Get the quoting style from the `QUOTING_STYLE` environment variable. -fn env_quoting_style() -> QuotingStyle { - match env::var("QUOTING_STYLE") { - Ok(style) => parse_quoting_style(&style).unwrap_or_else(|| { - // Warn when QUOTING_STYLE is set to a value we don't understand, - // then fall back to the default. - show_error!( - "{}", - translate!("stat-warning-invalid-env-quoting-style", "style" => style.clone()) - ); - QuotingStyle::SHELL_ESCAPE - }), - Err(_) => QuotingStyle::SHELL_ESCAPE, - } -} - fn get_quoted_file_name( display_name: &str, file: &OsString, @@ -1106,7 +1075,11 @@ impl Stater { } fn quoting_style(&self) -> QuotingStyle { - *self.quoting_style.get_or_init(env_quoting_style) + *self.quoting_style.get_or_init(|| { + quoting_style_from_env() + .unwrap_or(QuotingStyle::SHELL_ESCAPE) + .show_control(true) + }) } fn find_mount_point>(&self, p: P) -> Option<&OsString> { From 7eb337913ed6efc91e012106be815e02cc4fa4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Mon, 28 Sep 2026 12:57:37 +0200 Subject: [PATCH 8/9] numfmt: Use common API for locale-aware quoting --- src/uu/numfmt/src/options.rs | 14 ++------------ src/uucore/src/lib/features/quoting_style/mod.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index 871bd250b38..009d1492351 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -3,7 +3,6 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use std::ffi::OsStr; use std::fmt::Display; use std::iter::Peekable; // `Range` alone is the field range from uucore, so byte ranges are named apart. @@ -11,7 +10,7 @@ use std::ops::Range as ByteRange; use std::str::{CharIndices, FromStr}; use crate::units::Unit; -use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; +use uucore::quoting_style::locale_aware_c_single_escape; use uucore::ranges::Range; use uucore::translate; @@ -26,15 +25,6 @@ fn at(iter: &mut Peekable>, s: &str) -> ByteRange { .map_or(s.len()..s.len(), |&(i, c)| i..i + c.len_utf8()) } -/// The format quoted the way an error message shows it: in single quotes, with -/// control characters written as C escapes. Without this a format holding a -/// newline or a tab would split the message across lines or move the cursor. -fn quote_format(s: &str) -> String { - locale_aware_escape_name(OsStr::new(s), QuotingStyle::C_SINGLE) - .to_string_lossy() - .into_owned() -} - pub const DEBUG: &str = "debug"; pub const DELIMITER: &str = "delimiter"; pub const FIELD: &str = "field"; @@ -234,7 +224,7 @@ impl FromStr for FormatOptions { // can say where in the format it gave up. let mut iter = s.char_indices().peekable(); let mut options = Self::default(); - let quoted = quote_format(s); + let quoted = locale_aware_c_single_escape(s); let error = |message: String, span: ByteRange, kind: FormatErrorKind| FormatError { message, span, diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 1ee9e5b6192..b59eb9f6a4d 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -324,6 +324,15 @@ pub fn locale_aware_shell_escape(name: impl AsRef) -> String { .unwrap() // SAFETY: string was just escaped } +/// Shorthand function for [`locale_aware_escape_name`] +/// Useful for quoting in error messages. +#[inline(always)] +pub fn locale_aware_c_single_escape(name: impl AsRef) -> String { + locale_aware_escape_name(name.as_ref(), QuotingStyle::C_SINGLE) + .into_string() + .unwrap() // SAFETY: string was just escaped +} + /// Escape a directory name with respect to the given style. /// This is mainly meant to be used for ls' directory name printing and is not /// likely to be used elsewhere. From 5283a80158b7b6fc7c1268c08b7c46e658708b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20P=C3=A9ron?= Date: Fri, 25 Sep 2026 13:25:39 +0200 Subject: [PATCH 9/9] quoting: Replace uses of locale_aware_escape_name with locale_aware_shell_escape --- src/uu/head/src/head.rs | 19 +++++----------- src/uu/tail/src/paths.rs | 7 +++--- src/uu/wc/src/wc.rs | 14 ++++-------- .../src/lib/features/checksum/validate.rs | 22 ++++++------------- src/uucore/src/lib/features/format/spec.rs | 14 +++--------- .../src/lib/features/quoting_style/mod.rs | 4 ++-- 6 files changed, 25 insertions(+), 55 deletions(-) diff --git a/src/uu/head/src/head.rs b/src/uu/head/src/head.rs index 145477211c2..8164d28f769 100644 --- a/src/uu/head/src/head.rs +++ b/src/uu/head/src/head.rs @@ -17,12 +17,12 @@ use std::os::fd::AsFd; use std::path::Path; use std::path::PathBuf; use uucore::diagnostics::OptionValue; -use uucore::display::{Quotable, print_verbatim}; +use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError, strip_errno}; use uucore::line_ending::LineEnding; use uucore::parser::parse_signed_num::number_offset; use uucore::parser::parse_size::ParseSizeError; -use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; +use uucore::quoting_style::locale_aware_shell_escape; use uucore::show; use uucore::translate; @@ -449,11 +449,8 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { if !first { writeln!(stdout)?; } - let name = locale_aware_escape_name( - translate!("head-name-stdin").as_ref(), - QuotingStyle::SHELL_ESCAPE, - ); - writeln!(stdout, "==> {} <==", name.to_string_lossy())?; + let name = locale_aware_shell_escape(translate!("head-name-stdin")); + writeln!(stdout, "==> {name} <==")?; } let stdin = io::stdin(); @@ -503,12 +500,8 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { if !first { writeln!(stdout)?; } - write!(stdout, "==> ")?; - print_verbatim(locale_aware_escape_name( - file.as_ref(), - QuotingStyle::SHELL_ESCAPE, - ))?; - writeln!(stdout, " <==")?; + let name = locale_aware_shell_escape(file); + writeln!(stdout, "==> {name} <==")?; first = false; } Ok(()) diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index 611cb18ef80..42791a1e8d9 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -14,7 +14,7 @@ use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; #[cfg(not(target_os = "wasi"))] use uucore::error::UResult; -use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; +use uucore::quoting_style::locale_aware_shell_escape; use uucore::translate; #[derive(Debug, Clone)] @@ -134,12 +134,11 @@ impl HeaderPrinter { pub fn print(&mut self, string: &str) { if self.verbose { - // GNU quotes the name shown in the header when it needs it. - let name = locale_aware_escape_name(string.as_ref(), QuotingStyle::SHELL_ESCAPE); println!( "{}==> {} <==", if self.first_header { "" } else { "\n" }, - name.to_string_lossy(), + // GNU quotes the name shown in the header when it needs it. + locale_aware_shell_escape(string), ); self.first_header = false; } diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 965beaac913..60d586c873e 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -25,7 +25,7 @@ use std::{ use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; use unicode_width::UnicodeWidthChar; use utf8::{BufReadDecoder, BufReadDecoderError}; -use uucore::{display::Quotable, translate}; +use uucore::{display::Quotable, quoting_style::locale_aware_shell_escape, translate}; use uucore::{ error::{FromIo, UError, UResult}, @@ -270,7 +270,7 @@ impl<'a> Input<'a> { /// Converts input into the form that appears in errors. fn path_display(&self) -> String { match self { - Self::Path(path) => escape_name_wrapper(path.as_os_str()), + Self::Path(path) => locale_aware_shell_escape(path.as_os_str()), Self::Stdin(_) => translate!("wc-standard-input"), } } @@ -360,7 +360,7 @@ impl WcError { Some((input, idx)) => { let path = match input { Input::Stdin(_) => STDIN_REPR.into(), - Input::Path(path) => escape_name_wrapper(path.as_os_str()).into(), + Input::Path(path) => locale_aware_shell_escape(path.as_os_str()).into(), }; Self::ZeroLengthFileNameCtx { path, idx } } @@ -813,7 +813,7 @@ fn files0_iter<'a>( } } Err(e) => Err(e.map_err_context( - || translate!("wc-error-read-error", "path" => escape_name_wrapper(&err_path)), + || translate!("wc-error-read-error", "path" => locale_aware_shell_escape(&err_path)), ) as Box), }), ); @@ -827,12 +827,6 @@ fn files0_iter<'a>( }) } -fn escape_name_wrapper(name: &OsStr) -> String { - quoting_style::locale_aware_escape_name(name, QuotingStyle::SHELL_ESCAPE) - .into_string() - .expect("All escaped names with the escaping option return valid strings.") -} - fn hardware_feature_label(feature: HardwareFeature) -> &'static str { match feature { HardwareFeature::Avx512 => "AVX512F", diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index 048b027c16f..645b80495ae 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -19,7 +19,7 @@ use crate::checksum::{ digest_reader, parse_blake_length, unescape_filename, }; use crate::error::{FromIo, UError, UResult, USimpleError, strip_errno}; -use crate::quoting_style::{QuotingStyle, locale_aware_escape_name}; +use crate::quoting_style::locale_aware_shell_escape; use crate::sum::{self, Blake2b, Blake3, DigestOutput}; use crate::{ os_str_as_bytes, os_str_from_bytes, read_os_string_lines, show, show_warning_caps, translate, @@ -245,9 +245,8 @@ fn write_file_report( verbose: ChecksumVerbose, ) -> io::Result<()> { if result.can_display(verbose) { - let filename = locale_aware_escape_name(filename, QuotingStyle::SHELL_ESCAPE); - // Here, .to_string_lossy() is lossless thanks to the escaping. - writeln!(w, "{}: {result}", filename.to_string_lossy())?; + let filename = locale_aware_shell_escape(filename); + writeln!(w, "{filename}: {result}")?; } Ok(()) } @@ -553,12 +552,7 @@ fn get_file_to_check( ); }; let print_error = |err: io::Error| { - show!(err.map_err_context(|| { - locale_aware_escape_name(filename, QuotingStyle::SHELL_ESCAPE) - // This is non destructive thanks to the escaping - .to_string_lossy() - .to_string() - })); + show!(err.map_err_context(|| { locale_aware_shell_escape(filename) })); }; match File::open(filename) { Ok(f) => { @@ -697,11 +691,9 @@ fn compute_and_check_digest_from_file( match digest_reader(&mut digest, &mut file_reader, ReadingMode::Binary) { Ok(result) => result, Err(err) => { - show!(err.map_err_context(|| { - locale_aware_escape_name(real_filename_to_check, QuotingStyle::SHELL_ESCAPE) - .to_string_lossy() - .to_string() - })); + show!( + err.map_err_context(|| { locale_aware_shell_escape(real_filename_to_check) }) + ); let _ = write_file_report( io::stdout(), diff --git a/src/uucore/src/lib/features/format/spec.rs b/src/uucore/src/lib/features/format/spec.rs index 47966f59601..069d3069ad0 100644 --- a/src/uucore/src/lib/features/format/spec.rs +++ b/src/uucore/src/lib/features/format/spec.rs @@ -13,11 +13,7 @@ use super::{ }, parse_escape_only, }; -use crate::{ - format::FormatArguments, - os_str_as_bytes, - quoting_style::{QuotingStyle, locale_aware_escape_name}, -}; +use crate::{format::FormatArguments, os_str_as_bytes, quoting_style::locale_aware_shell_escape}; use std::{io::Write, num::NonZero, ops::ControlFlow}; /// A parsed specification for formatting a value @@ -406,12 +402,8 @@ impl Spec { writer.write_all(&parsed).map_err(FormatError::IoError) } Self::QuotedString { position } => { - let s = locale_aware_escape_name( - args.next_string(*position), - QuotingStyle::SHELL_ESCAPE, - ); - let bytes = os_str_as_bytes(&s)?; - writer.write_all(bytes).map_err(FormatError::IoError) + let s = locale_aware_shell_escape(args.next_string(*position)); + writer.write_all(s.as_bytes()).map_err(FormatError::IoError) } Self::SignedInt { width, diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index b59eb9f6a4d..b4850511127 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -312,7 +312,7 @@ pub fn escape_name(name: &OsStr, style: QuotingStyle, encoding: UEncoding) -> Os /// Retrieve the encoding from the locale and pass it to [`escape_name`]. #[inline(always)] pub fn locale_aware_escape_name(name: &OsStr, style: QuotingStyle) -> OsString { - escape_name(name, style, i18n::get_locale_encoding()) + escape_name(name, style, i18n::get_ctype_encoding()) } /// Shorthand function for [`locale_aware_escape_name`] @@ -344,7 +344,7 @@ pub fn escape_dir_name(dir_name: &OsStr, style: QuotingStyle, encoding: UEncodin /// Retrieve the encoding from the locale and pass it to [`escape_dir_name`]. pub fn locale_aware_escape_dir_name(name: &OsStr, style: QuotingStyle) -> OsString { - escape_dir_name(name, style, i18n::get_locale_encoding()) + escape_dir_name(name, style, i18n::get_ctype_encoding()) } #[cfg(test)]