From 1c50f0fea625fb79db6a5ae0dc8a7ac5ca6f2b15 Mon Sep 17 00:00:00 2001 From: Paolo Losi Date: Fri, 22 May 2026 18:00:44 +0200 Subject: [PATCH] ls: avoid color-induced quote alignment space When color is enabled, displayed names can start with ANSI escape sequences before their visible quote. Strip leading ANSI sequences before deciding whether GNU-style quote alignment needs to add a leading space.\n\nFixes uutils/coreutils#12432. --- src/uu/ls/src/display.rs | 24 ++++++++++++++++++++-- tests/by-util/test_ls.rs | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/uu/ls/src/display.rs b/src/uu/ls/src/display.rs index a286433cfd9..19655a84bea 100644 --- a/src/uu/ls/src/display.rs +++ b/src/uu/ls/src/display.rs @@ -482,7 +482,7 @@ fn display_grid( // ``` // FIXME: the Grid crate only supports &str, so can't display raw bytes buf.clear(); - if quoted && !os_str_starts_with(&n, b"'") && !os_str_starts_with(&n, b"\"") { + if quoted && !displayed_name_starts_with_quote(&n) { buf.push(b' '); } buf.extend(n.as_encoded_bytes()); @@ -968,7 +968,7 @@ fn display_item_long( LazyCell::new(|| ansi_width(&String::from_utf8_lossy(&state.display_buf))), ); - let needs_space = quoted && !os_str_starts_with(&item_display.displayed, b"'"); + let needs_space = quoted && !displayed_name_starts_with_quote(&item_display.displayed); if config.dired { let mut dired_name_len = item_display.dired_name_len; @@ -1340,6 +1340,26 @@ fn os_str_starts_with(haystack: &OsStr, needle: &[u8]) -> bool { os_str_as_bytes_lossy(haystack).starts_with(needle) } +fn displayed_name_starts_with_quote(name: &OsStr) -> bool { + let bytes = os_str_as_bytes_lossy(name); + let bytes = strip_leading_ansi_sequences(&bytes); + bytes.starts_with(b"'") || bytes.starts_with(b"\"") +} + +fn strip_leading_ansi_sequences(mut bytes: &[u8]) -> &[u8] { + while bytes.starts_with(b"\x1b[") { + let Some(end) = bytes[2..] + .iter() + .position(|&b| (0x40..=0x7e).contains(&b)) + .map(|end| end + 2) + else { + break; + }; + bytes = &bytes[end + 1..]; + } + bytes +} + fn write_os_str(writer: &mut W, string: &OsStr) -> std::io::Result<()> { writer.write_all(&os_str_as_bytes_lossy(string)) } diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 7afedc35a42..c342f20953a 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -3997,6 +3997,49 @@ fn test_ls_align_unquoted() { } } +#[test] +fn test_ls_color_does_not_make_quoted_names_align_as_unquoted() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("dir one"); + at.touch("file one"); + + let args = ["-C", "-T0", "-w=80", "--quoting-style=shell-escape-always"]; + + let plain = scene + .ucmd() + .args(&args) + .arg("--color=never") + .succeeds() + .stdout_move_str(); + let colored = scene + .ucmd() + .args(&args) + .arg("--color=always") + .succeeds() + .stdout_move_str(); + + let ansi_re = Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap(); + assert_eq!(ansi_re.replace_all(&colored, ""), plain); + + let long_args = ["-l", "--quoting-style=shell-escape-always"]; + let plain = scene + .ucmd() + .args(&long_args) + .arg("--color=never") + .succeeds() + .stdout_move_str(); + let colored = scene + .ucmd() + .args(&long_args) + .arg("--color=always") + .succeeds() + .stdout_move_str(); + + assert_eq!(ansi_re.replace_all(&colored, ""), plain); +} + #[test] fn test_ls_align_unquoted_multiline() { let scene = TestScenario::new(util_name!());