use std::io::IsTerminal;
use std::sync::OnceLock;
use console::Style;
pub const FORCE_HYPERLINK_ENV: &str = "FORCE_HYPERLINK";
fn paint(style: Style, text: &str) -> String {
style.apply_to(text).to_string()
}
pub fn heading(text: &str) -> String {
paint(Style::new().bold(), text)
}
pub fn name(text: &str) -> String {
paint(Style::new().cyan(), text)
}
pub fn good(text: &str) -> String {
paint(Style::new().green(), text)
}
pub fn warn(text: &str) -> String {
paint(Style::new().yellow(), text)
}
pub fn bad(text: &str) -> String {
paint(Style::new().red(), text)
}
pub fn dim(text: &str) -> String {
paint(Style::new().dim(), text)
}
pub fn link(text: &str, target: &str) -> String {
if !hyperlinks() || target.is_empty() {
return text.to_string();
}
format!("\x1b]8;;{target}\x1b\\{text}\x1b]8;;\x1b\\")
}
pub fn url(url: &str) -> String {
link(url, url)
}
pub fn pad(text: &str, plain: &str, width: usize) -> String {
let fill = width.saturating_sub(plain.chars().count());
format!("{text}{}", " ".repeat(fill))
}
fn hyperlinks() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
let env = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty());
hyperlinks_for(std::io::stdout().is_terminal(), &env)
})
}
fn hyperlinks_for(terminal: bool, env: &dyn Fn(&str) -> Option<String>) -> bool {
if let Some(forced) = env(FORCE_HYPERLINK_ENV) {
return forced != "0";
}
if !terminal {
return false;
}
if env("DOMTERM").is_some() || env("WT_SESSION").is_some() || env("KONSOLE_VERSION").is_some() {
return true;
}
if env("TERM_PROGRAM").is_some_and(|program| ["Hyper", "iTerm.app", "terminology", "WezTerm", "vscode", "ghostty"].contains(&program.as_str())) {
return true;
}
if env("VTE_VERSION")
.and_then(|version| version.parse::<u32>().ok())
.is_some_and(|version| version >= 5000)
{
return true;
}
env("TERM").is_some_and(|term| ["xterm-kitty", "alacritty", "foot", "xterm-ghostty"].contains(&term.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| value.to_string())
}
#[test]
fn links_are_written_only_for_terminals_that_read_them() {
assert!(hyperlinks_for(true, &env_of(&[("WT_SESSION", "1")])), "Windows Terminal");
assert!(hyperlinks_for(true, &env_of(&[("TERM_PROGRAM", "vscode")])));
assert!(hyperlinks_for(true, &env_of(&[("VTE_VERSION", "7600")])));
assert!(
!hyperlinks_for(true, &env_of(&[("VTE_VERSION", "4600")])),
"VTE before 0.50 prints the sequence"
);
assert!(!hyperlinks_for(true, &env_of(&[])), "an unknown terminal");
assert!(!hyperlinks_for(true, &env_of(&[("TERM_PROGRAM", "tmux")])));
assert!(!hyperlinks_for(false, &env_of(&[("WT_SESSION", "1")])), "a pipe inside Windows Terminal");
}
#[test]
fn the_override_decides_when_it_is_set() {
assert!(hyperlinks_for(false, &env_of(&[("FORCE_HYPERLINK", "1")])));
assert!(!hyperlinks_for(true, &env_of(&[("FORCE_HYPERLINK", "0"), ("WT_SESSION", "1")])));
}
#[test]
fn padding_counts_the_plain_text() {
assert_eq!(pad("\x1b[36mweb\x1b[0m", "web", 5), "\x1b[36mweb\x1b[0m ");
assert_eq!(pad("abcdef", "abcdef", 3), "abcdef", "never cut");
}
}