Skip to content

Commit 3f3e777

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 3f3e777

File tree

2 files changed

+131
-40
lines changed

2 files changed

+131
-40
lines changed

src/uu/ls/src/ls.rs

Lines changed: 44 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(
@@ -3463,29 +3480,35 @@ fn create_hyperlink(name: &OsStr, path: &PathData) -> OsString {
34633480
let hostname = hostname.to_string_lossy();
34643481

34653482
let absolute_path = fs::canonicalize(path.path()).unwrap_or_default();
3466-
let absolute_path = absolute_path.to_string_lossy();
34673483

3484+
// Use raw bytes to preserve invalid UTF-8 sequences instead of to_string_lossy()
3485+
use std::os::unix::ffi::OsStrExt;
3486+
let absolute_path_bytes = absolute_path.as_os_str().as_bytes();
3487+
3488+
// Create a set of safe ASCII bytes that don't need encoding
34683489
#[cfg(not(target_os = "windows"))]
3469-
let unencoded_chars = "_-.:~/";
3490+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/".bytes().collect();
34703491
#[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()
3492+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/\\".bytes().collect();
3493+
3494+
// Encode at byte level to properly handle UTF-8 sequences and preserve invalid UTF-8
3495+
let full_encoded_path: String = absolute_path_bytes
3496+
.iter()
3497+
.map(|&b| {
3498+
if b.is_ascii_alphanumeric() || unencoded_bytes.contains(&b) {
3499+
(b as char).to_string()
34793500
} else {
3480-
format!("%{:02x}", c as u8)
3501+
format!("%{b:02x}")
34813502
}
34823503
})
34833504
.collect();
34843505

3485-
// \x1b = ESC, \x07 = BEL
3486-
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{absolute_path}\x07").into();
3506+
// OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\
3507+
// \x1b = ESC, \x1b\\ = ESC backslash
3508+
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{full_encoded_path}\x1b\\").into();
34873509
ret.push(name);
3488-
ret.push("\x1b]8;;\x07");
3510+
ret.push("\x1b]8;;\x1b\\");
3511+
34893512
ret
34903513
}
34913514

tests/by-util/test_ls.rs

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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)