Skip to main content

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}