Skip to content

Commit 041313c

Browse files
committed
ls: fix hyperlink functionality to use correct OSC 8 format and handle symlink targets
Should fix tests/ls/hyperlink.sh
1 parent 4c1536b commit 041313c

File tree

2 files changed

+145
-41
lines changed

2 files changed

+145
-41
lines changed

src/uu/ls/src/ls.rs

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3406,13 +3406,30 @@ fn display_item_name(
34063406
config,
34073407
false,
34083408
);
3409-
name.push(color_name(
3410-
escaped_target,
3411-
&target_data,
3412-
style_manager,
3413-
None,
3414-
is_wrap(name.len()),
3415-
));
3409+
3410+
// Check if the target actually needs coloring
3411+
let md_option: Option<Metadata> = target_data
3412+
.metadata()
3413+
.cloned()
3414+
.or_else(|| target_data.p_buf.symlink_metadata().ok());
3415+
let style = style_manager.colors.style_for_path_with_metadata(
3416+
&target_data.p_buf,
3417+
md_option.as_ref(),
3418+
);
3419+
3420+
if style.is_some() {
3421+
// Only apply coloring if there's actually a style
3422+
name.push(color_name(
3423+
escaped_target,
3424+
&target_data,
3425+
style_manager,
3426+
None,
3427+
is_wrap(name.len()),
3428+
));
3429+
} else {
3430+
// For regular files with no coloring, just use plain text
3431+
name.push(escaped_target);
3432+
}
34163433
}
34173434
Err(_) => {
34183435
name.push(
@@ -3458,34 +3475,53 @@ fn display_item_name(
34583475
name
34593476
}
34603477

3478+
/// Cross-platform helper to get bytes from OsStr
3479+
fn get_os_str_bytes(os_str: &OsStr) -> Vec<u8> {
3480+
#[cfg(unix)]
3481+
{
3482+
use std::os::unix::ffi::OsStrExt;
3483+
os_str.as_bytes().to_vec()
3484+
}
3485+
#[cfg(windows)]
3486+
{
3487+
// On Windows, convert to UTF-8 bytes, handling invalid UTF-8 gracefully
3488+
os_str.to_string_lossy().as_bytes().to_vec()
3489+
}
3490+
}
3491+
34613492
fn create_hyperlink(name: &OsStr, path: &PathData) -> OsString {
34623493
let hostname = hostname::get().unwrap_or_else(|_| OsString::from(""));
34633494
let hostname = hostname.to_string_lossy();
34643495

34653496
let absolute_path = fs::canonicalize(path.path()).unwrap_or_default();
3466-
let absolute_path = absolute_path.to_string_lossy();
34673497

3498+
// Get bytes for URL encoding in a cross-platform way
3499+
let absolute_path_bytes = get_os_str_bytes(absolute_path.as_os_str());
3500+
3501+
// Create a set of safe ASCII bytes that don't need encoding
34683502
#[cfg(not(target_os = "windows"))]
3469-
let unencoded_chars = "_-.:~/";
3503+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/".bytes().collect();
34703504
#[cfg(target_os = "windows")]
3471-
let unencoded_chars = "_-.:~/\\";
3472-
3473-
// percentage encoding of path
3474-
let absolute_path: String = absolute_path
3475-
.chars()
3476-
.map(|c| {
3477-
if c.is_alphanumeric() || unencoded_chars.contains(c) {
3478-
c.to_string()
3505+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/\\".bytes().collect();
3506+
3507+
// Encode at byte level to properly handle UTF-8 sequences and preserve invalid UTF-8
3508+
let full_encoded_path: String = absolute_path_bytes
3509+
.iter()
3510+
.map(|&b: &u8| {
3511+
if b.is_ascii_alphanumeric() || unencoded_bytes.contains(&b) {
3512+
(b as char).to_string()
34793513
} else {
3480-
format!("%{:02x}", c as u8)
3514+
format!("%{b:02x}")
34813515
}
34823516
})
34833517
.collect();
34843518

3485-
// \x1b = ESC, \x07 = BEL
3486-
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{absolute_path}\x07").into();
3519+
// OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\
3520+
// \x1b = ESC, \x1b\\ = ESC backslash
3521+
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{full_encoded_path}\x1b\\").into();
34873522
ret.push(name);
3488-
ret.push("\x1b]8;;\x07");
3523+
ret.push("\x1b]8;;\x1b\\");
3524+
34893525
ret
34903526
}
34913527

tests/by-util/test_ls.rs

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
55
// 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
6-
// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry
6+
// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry awith acolons
77
#![allow(
88
clippy::similar_names,
99
clippy::too_many_lines,
@@ -5701,19 +5701,15 @@ fn test_ls_hyperlink() {
57015701

57025702
let result = scene.ucmd().arg("--hyperlink").succeeds();
57035703
assert!(result.stdout_str().contains("\x1b]8;;file://"));
5704-
assert!(
5705-
result
5706-
.stdout_str()
5707-
.contains(&format!("{path}{separator}{file}\x07{file}\x1b]8;;\x07"))
5708-
);
5704+
assert!(result.stdout_str().contains(&format!(
5705+
"{path}{separator}{file}\x1b\\{file}\x1b]8;;\x1b\\"
5706+
)));
57095707

57105708
let result = scene.ucmd().arg("--hyperlink=always").succeeds();
57115709
assert!(result.stdout_str().contains("\x1b]8;;file://"));
5712-
assert!(
5713-
result
5714-
.stdout_str()
5715-
.contains(&format!("{path}{separator}{file}\x07{file}\x1b]8;;\x07"))
5716-
);
5710+
assert!(result.stdout_str().contains(&format!(
5711+
"{path}{separator}{file}\x1b\\{file}\x1b]8;;\x1b\\"
5712+
)));
57175713

57185714
for argument in [
57195715
"--hyperlink=never",
@@ -5748,23 +5744,23 @@ fn test_ls_hyperlink_encode_link() {
57485744
assert!(
57495745
result
57505746
.stdout_str()
5751-
.contains("back%5cslash\x07back\\slash\x1b]8;;\x07")
5747+
.contains("back%5cslash\x1b\\back\\slash\x1b]8;;\x1b\\")
57525748
);
57535749
assert!(
57545750
result
57555751
.stdout_str()
5756-
.contains("ques%3ftion\x07ques?tion\x1b]8;;\x07")
5752+
.contains("ques%3ftion\x1b\\ques?tion\x1b]8;;\x1b\\")
57575753
);
57585754
}
57595755
assert!(
57605756
result
57615757
.stdout_str()
5762-
.contains("encoded%253Fquestion\x07encoded%3Fquestion\x1b]8;;\x07")
5758+
.contains("encoded%253Fquestion\x1b\\encoded%3Fquestion\x1b]8;;\x1b\\")
57635759
);
57645760
assert!(
57655761
result
57665762
.stdout_str()
5767-
.contains("sp%20ace\x07sp ace\x1b]8;;\x07")
5763+
.contains("sp%20ace\x1b\\sp ace\x1b]8;;\x1b\\")
57685764
);
57695765
}
57705766
// spell-checker: enable
@@ -5796,7 +5792,9 @@ fn test_ls_hyperlink_dirs() {
57965792
.lines()
57975793
.next()
57985794
.unwrap()
5799-
.contains(&format!("{path}{separator}{dir_a}\x07{dir_a}\x1b]8;;\x07:"))
5795+
.contains(&format!(
5796+
"{path}{separator}{dir_a}\x1b\\{dir_a}\x1b]8;;\x1b\\:"
5797+
))
58005798
);
58015799
assert_eq!(result.stdout_str().lines().nth(1).unwrap(), "");
58025800
assert!(
@@ -5805,7 +5803,9 @@ fn test_ls_hyperlink_dirs() {
58055803
.lines()
58065804
.nth(2)
58075805
.unwrap()
5808-
.contains(&format!("{path}{separator}{dir_b}\x07{dir_b}\x1b]8;;\x07:"))
5806+
.contains(&format!(
5807+
"{path}{separator}{dir_b}\x1b\\{dir_b}\x1b]8;;\x1b\\:"
5808+
))
58095809
);
58105810
}
58115811

@@ -5837,21 +5837,89 @@ fn test_ls_hyperlink_recursive_dirs() {
58375837
let mut lines = result.stdout_str().lines();
58385838
assert_hyperlink!(
58395839
lines.next(),
5840-
&format!("{path}{separator}{dir_a}\x07{dir_a}\x1b]8;;\x07:")
5840+
&format!("{path}{separator}{dir_a}\x1b\\{dir_a}\x1b]8;;\x1b\\:")
58415841
);
58425842
assert_hyperlink!(
58435843
lines.next(),
5844-
&format!("{path}{separator}{dir_a}{separator}{dir_b}\x07{dir_b}\x1b]8;;\x07")
5844+
&format!("{path}{separator}{dir_a}{separator}{dir_b}\x1b\\{dir_b}\x1b]8;;\x1b\\")
58455845
);
58465846
assert!(matches!(lines.next(), Some(l) if l.is_empty()));
58475847
assert_hyperlink!(
58485848
lines.next(),
58495849
&format!(
5850-
"{path}{separator}{dir_a}{separator}{dir_b}\x07{dir_a}{separator}{dir_b}\x1b]8;;\x07:"
5850+
"{path}{separator}{dir_a}{separator}{dir_b}\x1b\\{dir_a}{separator}{dir_b}\x1b]8;;\x1b\\:"
58515851
)
58525852
);
58535853
}
58545854

5855+
#[test]
5856+
fn test_ls_hyperlink_symlink_target_handling() {
5857+
let scene = TestScenario::new(util_name!());
5858+
let at = &scene.fixtures;
5859+
5860+
at.mkdir("target_dir");
5861+
at.touch("target_file.txt");
5862+
at.symlink_file("target_file.txt", "link_to_file");
5863+
at.symlink_dir("target_dir", "link_to_dir");
5864+
at.symlink_file("nonexistent", "link_to_missing");
5865+
5866+
let result = scene
5867+
.ucmd()
5868+
.args(&["-l", "--hyperlink", "--color"])
5869+
.succeeds();
5870+
let output = result.stdout_str();
5871+
5872+
assert!(output.contains("\x1b]8;;file://"));
5873+
assert!(!output.contains("\x07"));
5874+
assert!(output.contains("\x1b\\"));
5875+
5876+
let file_link_line = output
5877+
.lines()
5878+
.find(|line| line.contains("link_to_file"))
5879+
.unwrap();
5880+
assert!(file_link_line.contains(" -> "));
5881+
let target_part = file_link_line.split(" -> ").nth(1).unwrap();
5882+
assert!(!target_part.contains("\x1b["));
5883+
assert!(!target_part.ends_with("\x1b[K"));
5884+
5885+
let dir_link_line = output
5886+
.lines()
5887+
.find(|line| line.contains("link_to_dir"))
5888+
.unwrap();
5889+
assert!(dir_link_line.contains(" -> "));
5890+
let target_part = dir_link_line.split(" -> ").nth(1).unwrap();
5891+
assert!(target_part.contains("\x1b["));
5892+
5893+
let missing_link_line = output
5894+
.lines()
5895+
.find(|line| line.contains("link_to_missing"))
5896+
.unwrap();
5897+
assert!(missing_link_line.contains(" -> "));
5898+
let missing_target_part = missing_link_line.split(" -> ").nth(1).unwrap();
5899+
assert!(missing_target_part.contains("\x1b["));
5900+
}
5901+
5902+
#[test]
5903+
fn test_ls_hyperlink_utf8_encoding() {
5904+
let scene = TestScenario::new(util_name!());
5905+
let at = &scene.fixtures;
5906+
5907+
at.touch("café.txt");
5908+
at.touch("file:with:colons.txt");
5909+
at.touch("file with spaces.txt");
5910+
5911+
let result = scene.ucmd().args(&["--hyperlink"]).succeeds();
5912+
let output = result.stdout_str();
5913+
5914+
assert!(output.contains("caf%c3%a9.txt"));
5915+
assert!(output.contains("file%3awith%3acolons.txt"));
5916+
assert!(output.contains("file%20with%20spaces.txt"));
5917+
5918+
let hyperlink_count = output.matches("\x1b]8;;file://").count();
5919+
let terminator_count = output.matches("\x1b\\").count();
5920+
assert_eq!(terminator_count, hyperlink_count * 2);
5921+
}
5922+
58555923
#[test]
58565924
fn test_ls_color_do_not_reset() {
58575925
let scene: TestScenario = TestScenario::new(util_name!());

0 commit comments

Comments
 (0)