Skip to content

Commit 4497e81

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 4497e81

File tree

2 files changed

+137
-41
lines changed

2 files changed

+137
-41
lines changed

src/uu/ls/src/ls.rs

Lines changed: 43 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,34 @@ 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+
// Get bytes for URL encoding in a cross-platform way
3485+
let absolute_path_bytes = os_str_as_bytes_lossy(absolute_path.as_os_str());
3486+
3487+
// Create a set of safe ASCII bytes that don't need encoding
34683488
#[cfg(not(target_os = "windows"))]
3469-
let unencoded_chars = "_-.:~/";
3489+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/".bytes().collect();
34703490
#[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()
3491+
let unencoded_bytes: std::collections::HashSet<u8> = "_-.~/\\:".bytes().collect();
3492+
3493+
// Encode at byte level to properly handle UTF-8 sequences and preserve invalid UTF-8
3494+
let full_encoded_path: String = absolute_path_bytes
3495+
.iter()
3496+
.map(|&b: &u8| {
3497+
if b.is_ascii_alphanumeric() || unencoded_bytes.contains(&b) {
3498+
(b as char).to_string()
34793499
} else {
3480-
format!("%{:02x}", c as u8)
3500+
format!("%{b:02x}")
34813501
}
34823502
})
34833503
.collect();
34843504

3485-
// \x1b = ESC, \x07 = BEL
3486-
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{absolute_path}\x07").into();
3505+
// OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\
3506+
// \x1b = ESC, \x1b\\ = ESC backslash
3507+
let mut ret: OsString = format!("\x1b]8;;file://{hostname}{full_encoded_path}\x1b\\").into();
34873508
ret.push(name);
3488-
ret.push("\x1b]8;;\x07");
3509+
ret.push("\x1b]8;;\x1b\\");
3510+
34893511
ret
34903512
}
34913513

tests/by-util/test_ls.rs

Lines changed: 94 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,95 @@ 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+
#[cfg(not(target_os = "windows"))]
5909+
at.touch("file:with:colons.txt");
5910+
#[cfg(target_os = "windows")]
5911+
at.touch("file-with-colons.txt");
5912+
at.touch("file with spaces.txt");
5913+
5914+
let result = scene.ucmd().args(&["--hyperlink"]).succeeds();
5915+
let output = result.stdout_str();
5916+
5917+
assert!(output.contains("caf%c3%a9.txt"));
5918+
#[cfg(not(target_os = "windows"))]
5919+
assert!(output.contains("file%3awith%3acolons.txt"));
5920+
#[cfg(target_os = "windows")]
5921+
assert!(output.contains("file-with-colons.txt"));
5922+
assert!(output.contains("file%20with%20spaces.txt"));
5923+
5924+
let hyperlink_count = output.matches("\x1b]8;;file://").count();
5925+
let terminator_count = output.matches("\x1b\\").count();
5926+
assert_eq!(terminator_count, hyperlink_count * 2);
5927+
}
5928+
58555929
#[test]
58565930
fn test_ls_color_do_not_reset() {
58575931
let scene: TestScenario = TestScenario::new(util_name!());

0 commit comments

Comments
 (0)