uncurses/ansi/hyperlink.rs
1//! OSC 8 hyperlink writer and parser.
2//!
3//! ## Category
4//!
5//! OSC 8 annotates following text with a URI until a closing OSC 8 with an empty
6//! URI is emitted. This module writes start/end markers and parses the body of an
7//! already-framed OSC 8 string.
8//!
9//! ## OSC framing
10//!
11//! Writers use the 7-bit OSC introducer and the `ST` terminator (`ESC \\`):
12//!
13//! ```text
14//! ESC ] 8 ; params ; uri ESC \\ text ESC ] 8 ; ; ESC \\
15//! ──┬── ┬ ───┬── ┬ ──┬── ───── close ─────
16//! OSC code attrs target ST
17//! ```
18//!
19//! ## Mode interaction
20//!
21//! Hyperlinks are not controlled by an ANSI/DEC mode. They are zero-width string
22//! controls and are preserved by width-aware text utilities.
23
24use std::io::{self, Write};
25
26/// Bytes that reset the current OSC 8 hyperlink: `ESC ] 8 ; ; ESC \`.
27///
28/// Writing this empty-URI OSC 8 sequence closes any hyperlink opened by
29/// [`write_hyperlink`]; writing it with no hyperlink open is a harmless no-op.
30pub const HYPERLINK_RESET: &[u8] = b"\x1b]8;;\x1b\\";
31
32/// Begin an OSC 8 hyperlink with `ESC ] 8 ; <params> ; <url> ESC \`.
33///
34/// `params` and `url` are emitted verbatim. The hyperlink applies to following
35/// printable text until [`HYPERLINK_RESET`] is written.
36pub fn write_hyperlink<W: Write>(w: &mut W, url: &str, params: &str) -> io::Result<()> {
37 write!(w, "\x1b]8;{params};{url}\x1b\\")
38}
39
40/// Parse an OSC 8 body into `(params, url)`.
41///
42/// `body` must exclude the OSC introducer and terminator, start with `8;`, and contain the separator between params and URL. The URL may contain additional semicolons; only the first semicolon after `8;` separates params from URL. Returns `None` for malformed or non-UTF-8 bodies.
43pub fn parse_hyperlink(body: &[u8]) -> Option<(&str, &str)> {
44 let rest = body.strip_prefix(b"8;")?;
45 let sep = rest.iter().position(|&b| b == b';')?;
46 let params = std::str::from_utf8(&rest[..sep]).ok()?;
47 let url = std::str::from_utf8(&rest[sep + 1..]).ok()?;
48 Some((params, url))
49}
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54
55 #[test]
56 fn test_hyperlink() {
57 let mut buf = Vec::new();
58 write_hyperlink(&mut buf, "https://example.com", "").unwrap();
59 assert_eq!(buf, b"\x1b]8;;https://example.com\x1b\\");
60 }
61
62 #[test]
63 fn parse_basic() {
64 let (params, url) = parse_hyperlink(b"8;;https://example.com").unwrap();
65 assert_eq!(params, "");
66 assert_eq!(url, "https://example.com");
67 }
68
69 #[test]
70 fn parse_with_params() {
71 let (params, url) = parse_hyperlink(b"8;id=abc;https://x").unwrap();
72 assert_eq!(params, "id=abc");
73 assert_eq!(url, "https://x");
74 }
75
76 #[test]
77 fn parse_close() {
78 // `8;;` is the close-link form (empty url).
79 let (params, url) = parse_hyperlink(b"8;;").unwrap();
80 assert_eq!(params, "");
81 assert_eq!(url, "");
82 }
83
84 #[test]
85 fn parse_url_with_semicolons() {
86 // Only the first `;` after `8;` separates params from url; the url
87 // keeps the rest of the body verbatim.
88 let (params, url) = parse_hyperlink(b"8;id=1;https://x?a=1;b=2").unwrap();
89 assert_eq!(params, "id=1");
90 assert_eq!(url, "https://x?a=1;b=2");
91 }
92
93 #[test]
94 fn parse_rejects_wrong_prefix() {
95 assert!(parse_hyperlink(b"0;title").is_none());
96 assert!(parse_hyperlink(b"").is_none());
97 }
98
99 #[test]
100 fn parse_rejects_missing_separator() {
101 // `8;url` has only one `;` after the prefix.
102 assert!(parse_hyperlink(b"8;noseparator").is_none());
103 }
104
105 #[test]
106 fn parse_rejects_invalid_utf8() {
107 assert!(parse_hyperlink(b"8;\xff;url").is_none());
108 assert!(parse_hyperlink(b"8;;\xff").is_none());
109 }
110}