//! Clickable text for terminals that render OSC 8 hyperlinks: an `@handle` //! opens its Bluesky profile, a Tangled URL opens itself, in every place //! that already prints one. The escape is invisible everywhere it should //! be — a script reading stdout, output redirected to a file, a terminal //! that predates OSC 8 — because those all fail [`supported`] and get back //! the same plain text they always printed. use std::io::IsTerminal; /// Whether stdout will render an escape sequence at all — colour /// (`about.rs`, `repo.rs`, `review.rs`, `logs/oauth.rs`) and the OSC 8 links /// this module wraps text in both stand or fall on the same three facts, so /// there is exactly one function that knows them rather than each caller /// keeping its own copy: /// /// - `is_terminal`: a pipe, a redirect or a file gets plain text, so the /// output stays greppable and byte-stable. /// - `no_color`: `NO_COLOR` set to anything non-empty /// () is an explicit opt out, terminal or not. /// - `term_is_dumb`: `TERM=dumb` is a terminal *identifying itself* as /// unable to render any escape sequence, colour included, which is why /// this is not an OSC-8-only concern — git, GNU `ls --color=auto` and GNU /// `ls --hyperlink=auto` all treat it the same way, and a caller drawing /// colour on a terminal that just said it can't is the bug this rules out. /// /// A free function of three bools rather than a struct read from the /// environment so a test can hold all eight combinations still instead of /// mutating `TERM` or `NO_COLOR` on the process — which `cargo test`'s /// shared, threaded environment makes unsafe to do at all (see [`crate::docs::testing`]). pub fn escapes_wanted(is_terminal: bool, no_color: bool, term_is_dumb: bool) -> bool { is_terminal && !no_color && !term_is_dumb } /// [`escapes_wanted`], read from this process's actual stdout and /// environment — what every caller outside a test wants. /// /// `--json` is a fourth veto, and it sits here rather than in the pure /// function above because it is not a fact about the terminal: escapes are /// off in that mode even on the most capable tty, since stdout is then a /// document rather than a display (see [`crate::term::jsonout`], rule 3). Keeping /// it at this one gate is what makes the rule hold for every colour and /// link decision in the tool at once, instead of each `--json` branch /// having to remember not to call a formatter. pub fn stdout_escapes_wanted() -> bool { !crate::term::jsonout::active() && escapes_wanted( std::io::stdout().is_terminal(), std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()), std::env::var_os("TERM").is_some_and(|t| t == "dumb"), ) } /// This is an allowlist's opposite on purpose — OSC 8 went from /// experimental to nearly ubiquitous (iTerm2, Kitty, WezTerm, Alacritty, /// Ghostty, Windows Terminal, VTE and everything built on it) over a few /// years, and an allowlist would need a new entry for every terminal that /// added support since. GNU `ls --hyperlink=auto` ships this same policy, /// which is why it is followed here instead of invented fresh. pub fn supported() -> bool { stdout_escapes_wanted() } /// The OSC 8 escape itself, unconditional. Split from [`wrap`] so the byte /// sequence has one definition a test can check without faking a tty. fn osc8(url: &str, text: &str) -> String { format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") } /// `text`, linking to `url` when [`supported`] — otherwise `text` unchanged, /// the fallback every caller already printed before this module existed. pub fn wrap(url: &str, text: &str) -> String { wrap_when(supported(), url, text) } /// [`wrap`] with the decision passed in, for a caller that has to make it /// before the text exists: `auth login` writes a whole different sentence /// depending on whether the URL can be a link, so it asks [`supported`] /// once and hands the answer down to a function a test can hold still. pub fn wrap_when(clickable: bool, url: &str, text: &str) -> String { if clickable { osc8(url, text) } else { text.to_string() } } /// `@handle`, linked to its Bluesky profile. The default destination for /// any atproto handle, not a Bluesky-specific one: bsky.app resolves a /// profile for every handle on the network, including accounts with no /// Bluesky-side presence beyond the identity itself. /// /// **The string is not ours.** It comes back from an index, or out of a DID /// document served by a host the subject of the DID chose, and it lands in /// the middle of an OSC 8 escape — so a handle carrying `\x1b\\` closes the /// sequence early and the rest of it is read by the terminal rather than /// drawn. [`is_a_handle`] is the guard, and a string that fails it prints as /// [`crate::term::text::one_line`] left it, with no link on it. /// /// Unlinked rather than linked-after-cleaning, which is the same call /// [`cut_account`] already makes one function down: a link is a promise that /// a profile is on the other end, and the cleaned form of a handle nobody /// could have is a handle nobody has either. Refusing to link is the honest /// half, and it is also the strict one — the URL is then only ever built out /// of a string that has already been checked. pub fn handle(raw: &str) -> String { if !is_a_handle(raw) { return format!("@{}", crate::term::text::one_line(raw)); } wrap( &format!("https://bsky.app/profile/{raw}"), &format!("@{raw}"), ) } /// Whether `raw` could be an atproto handle: the shape, not the identity. /// /// A handle is a domain name (), so ASCII /// letters, digits, `-` and `.` are the whole alphabet — an internationalised /// one reaches the wire as punycode, which is in that set. Deliberately not /// [`crate::lexicon::identity`]'s validator, which asks jacquard and refuses /// the reserved TLDs: that answers "is this an account somebody could log in /// as", and the question here is only "can this be pasted into a URL and an /// escape sequence without changing what either one means". Borrowing the /// stricter one would unlink real accounts over a rule about resolvability, /// on a display path, and drag `lexicon/` into `term/` to do it. fn is_a_handle(raw: &str) -> bool { !raw.is_empty() && raw .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') } /// How to name an account to a person: `@handle` linked to its profile, and /// the DID only when no handle is known. /// /// **The shape this replaces was written out six times** — `handle.map(|h| /// format!("@{h}")).unwrap_or_else(|| did.clone())` — in the comment thread, /// the key listing, both issue readers, the pull labels and `repo /// configure`. Six copies of one rule is six chances to differ, and they /// already had: none of them linked, and `key list` went on to print /// `{who} ({did})`, so an account with no handle came out as its DID twice. /// /// A DID is what a service is told and a handle is what a person is shown. /// Where both are known the handle is the answer and the DID is noise; where /// only the DID is known it is all there is, and printing it once is the /// most that can be said. /// /// The DID is somebody else's string too, and it is the branch with no /// [`handle`] under it to check a shape. It is cleaned rather than validated: /// this module has no business deciding what a DID method's syntax allows, /// and dropping the characters a terminal *acts on* needs no such rule. pub fn account(handle: Option<&str>, did: &str) -> String { match handle { Some(h) => self::handle(h.trim_start_matches('@')), None => crate::term::text::one_line(did), } } /// The same, for a label a column has already cut to width. /// /// A truncated handle is deliberately left unlinked: `@verylonghandl…` is /// not an account, and a link under it would open a profile page for /// somebody who does not exist. The cut has to happen first — [`ellipsize`] /// counts characters and would slice an escape sequence in half — so this is /// the second half of that pair, applied to what survived. /// /// [`ellipsize`]: crate::term::column::ellipsize pub fn cut_account(label: &str) -> String { match label.strip_prefix('@') { Some(h) if !h.ends_with('\u{2026}') => handle(h), _ => crate::term::text::one_line(label), } } /// A URL, linked to itself. For the `tangled.org` lines atgc already prints /// as plain text to read or copy — this makes the same line also openable, /// without changing what it says. pub fn url(url: &str) -> String { wrap(url, url) } #[cfg(test)] mod tests { use super::{account, cut_account, escapes_wanted, is_a_handle, osc8}; /// Escapes are off under a non-terminal stdout, which is what a test /// binary has, so both helpers reduce to the text they choose. That is /// the half worth pinning: *which* string is picked, not how it is /// painted. #[test] fn an_account_is_named_by_its_handle_and_falls_back_to_the_did() { assert_eq!(account(Some("alice.test"), "did:plc:abc"), "@alice.test"); // Already prefixed upstream, and prefixing it twice is a name // nobody has. assert_eq!(account(Some("@alice.test"), "did:plc:abc"), "@alice.test"); // The DID exactly once. `key list` used to print it beside a `who` // that had already fallen back to it. assert_eq!(account(None, "did:plc:abc"), "did:plc:abc"); } /// A column cuts before this runs, so the truncated case is the one that /// matters: `@verylonghandl\u{2026}` names nobody, and linking it would /// promise a profile that does not exist. #[test] fn a_cut_handle_is_left_unlinked() { assert_eq!(cut_account("@alice.test"), "@alice.test"); assert_eq!( cut_account("@verylonghandl\u{2026}"), "@verylonghandl\u{2026}" ); assert_eq!(cut_account("did:plc:abc"), "did:plc:abc"); assert_eq!(cut_account(""), ""); } /// The exact byte sequence, independent of whether this test's stdout /// happens to be a terminal — `wrap`'s gate is [`super::supported`], /// tested separately from the escape it produces. #[test] fn the_escape_wraps_text_in_the_link_and_closes_it() { assert_eq!( osc8("https://example.com", "text"), "\x1b]8;;https://example.com\x1b\\text\x1b]8;;\x1b\\" ); } /// All eight combinations of the three facts, pinned directly rather /// than through `supported()` — this is the function every colour gate /// shares with the hyperlink one, so a regression here is a regression /// in five places at once. #[test] fn escapes_want_a_terminal_that_is_not_dumb_and_has_not_opted_out() { assert!(escapes_wanted(true, false, false)); assert!(!escapes_wanted(false, false, false), "not a terminal"); assert!(!escapes_wanted(true, true, false), "NO_COLOR set"); assert!(!escapes_wanted(true, false, true), "TERM=dumb"); assert!(!escapes_wanted(false, true, false)); assert!(!escapes_wanted(false, false, true)); assert!(!escapes_wanted(true, true, true)); assert!(!escapes_wanted(false, true, true)); } /// cargo test captures stdout, so it is never a terminal here — this /// is the same fallback a pipe or a redirect gets in real use, and it /// is worth asserting explicitly rather than only relying on `about.rs` /// and `repo.rs` having established the pattern works. #[test] fn wrap_is_plain_text_off_a_terminal() { assert_eq!(super::wrap("https://example.com", "text"), "text"); } #[test] fn a_handle_off_a_terminal_is_still_at_prefixed() { assert_eq!(super::handle("permadeath.com"), "@permadeath.com"); } /// The handle goes inside the OSC 8 escape *and* inside its URL, so a /// string that is not shaped like a domain name is never allowed to reach /// either. Asserted on the guard directly: a test binary's stdout is not /// a terminal, so `handle` would flatten linked and unlinked into the /// same plain text and hide the difference this is about. #[test] fn only_a_domain_shaped_string_is_linked() { assert!(is_a_handle("permadeath.com")); assert!(is_a_handle("alice.test")); assert!(is_a_handle("xn--80ak6aa92e.com"), "punycode is ASCII"); assert!(!is_a_handle("")); assert!(!is_a_handle("evil.example\x1b\\"), "closes the escape"); assert!(!is_a_handle("a b.com"), "a space is not in a domain name"); assert!(!is_a_handle("évil.com"), "not on the wire in that form"); } /// A handle that fails the shape check prints unlinked, and cleaned: /// `@` plus the text, with nothing a terminal would act on left in it. #[test] fn a_handle_that_is_not_one_prints_without_a_link() { // The escape that closes the OSC 8 sequence early, which is the // sequence the bug report named. assert_eq!( super::handle("evil.example\x1b\\x\x1b]8;;\x1b\\"), "@evil.example\\x]8;;\\" ); assert_eq!(super::handle("\x1b[2Jgone"), "@[2Jgone"); // And it is unlinked even where escapes are wanted, because the gate // is the shape of the string rather than the terminal. assert!(!super::handle("evil.example\x1b\\").contains('\x1b')); } /// The DID branch has no shape to check, so it is cleaned instead — this /// is the one a comment thread reaches for every author the index could /// not resolve. #[test] fn a_did_that_is_all_a_thread_knows_is_still_cleaned() { assert_eq!( account(None, "did:plc:abc\x1b]8;;x\x1b\\"), "did:plc:abc]8;;x\\" ); assert_eq!( cut_account("did:plc:abc\r\nforged row"), "did:plc:abc forged row" ); } #[test] fn a_url_off_a_terminal_is_unchanged() { assert_eq!( super::url("https://tangled.org/permadeath.com/atgc"), "https://tangled.org/permadeath.com/atgc" ); } }