1use std::fmt;
42use std::io::Write;
43
44use crate::capabilities::{Environment, Field, Origin, SystemEnvironment, parse_bool};
45
46pub const CLIPBOARD_VAR: &str = "RICH_CLIPBOARD";
49
50pub const MAX_BYTES: usize = 74_994;
54
55const PROGRAMS: &[&str] = &[
57 "iTerm.app",
58 "WezTerm",
59 "ghostty",
60 "vscode",
61 "rio",
62 "contour",
63];
64
65const TERMS: &[&str] = &[
67 "xterm-kitty",
68 "xterm-ghostty",
69 "alacritty",
70 "foot",
71 "contour",
72 "wezterm",
73 "rio",
74];
75
76pub fn detect(env: &dyn Environment) -> Field<bool> {
78 let get = |name: &str| env.var(name).filter(|value| !value.is_empty());
79 let field = |value: bool, origin: Origin, reason: String| Field {
80 value,
81 origin,
82 reason,
83 };
84 let var = |name: &str| Origin::Environment(name.to_string());
85 if !env.is_terminal() {
88 return field(false, Origin::Inferred, "not a terminal".into());
89 }
90 if let Some(raw) = get(CLIPBOARD_VAR)
91 && let Some(value) = parse_bool(&raw)
92 {
93 return field(value, var(CLIPBOARD_VAR), format!("{CLIPBOARD_VAR}={raw}"));
94 }
95 let term = get("TERM").unwrap_or_default();
96 let lower = term.to_ascii_lowercase();
97 if lower == "dumb" {
98 return field(false, var("TERM"), "TERM=dumb".into());
99 }
100 if get("TMUX").is_some() {
101 return field(false, var("TMUX"), "inside tmux".into());
102 }
103 if lower.starts_with("screen") || lower.starts_with("tmux") {
104 return field(false, var("TERM"), "inside a multiplexer".into());
105 }
106 if get("KITTY_WINDOW_ID").is_some() {
107 return field(true, var("KITTY_WINDOW_ID"), "kitty".into());
108 }
109 if get("WT_SESSION").is_some() {
110 return field(true, var("WT_SESSION"), "Windows Terminal".into());
111 }
112 if let Some(program) = get("TERM_PROGRAM")
113 && PROGRAMS.iter().any(|p| program.eq_ignore_ascii_case(p))
114 {
115 return field(true, var("TERM_PROGRAM"), format!("TERM_PROGRAM={program}"));
116 }
117 if TERMS.iter().any(|t| lower.starts_with(t)) {
118 return field(true, var("TERM"), format!("TERM={term}"));
119 }
120 if get("ALACRITTY_WINDOW_ID").is_some() {
121 return field(true, var("ALACRITTY_WINDOW_ID"), "Alacritty".into());
122 }
123 field(
124 false,
125 Origin::Default,
126 "terminal not known to accept OSC 52".into(),
127 )
128}
129
130#[derive(Debug)]
132pub enum ClipboardError {
133 Unsupported(String),
135 TooLarge { bytes: usize },
137 Io(std::io::Error),
139}
140
141impl fmt::Display for ClipboardError {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 ClipboardError::Unsupported(reason) => {
145 write!(
146 f,
147 "the terminal clipboard is off ({reason}; set {CLIPBOARD_VAR}=1 to force it)"
148 )
149 }
150 ClipboardError::TooLarge { bytes } => write!(
151 f,
152 "{bytes} bytes is too much to copy through the terminal (at most {MAX_BYTES})"
153 ),
154 ClipboardError::Io(error) => write!(f, "could not copy: {error}"),
155 }
156 }
157}
158
159impl std::error::Error for ClipboardError {}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Clipboard {
164 enabled: Field<bool>,
165}
166
167impl Clipboard {
168 pub fn detect(env: &dyn Environment) -> Clipboard {
170 Clipboard {
171 enabled: detect(env),
172 }
173 }
174
175 pub fn system() -> Clipboard {
178 Clipboard::detect(&SystemEnvironment)
179 }
180
181 pub fn forced(on: bool) -> Clipboard {
183 Clipboard {
184 enabled: Field {
185 value: on,
186 origin: Origin::Override,
187 reason: "override".into(),
188 },
189 }
190 }
191
192 pub fn enabled(&self) -> bool {
194 self.enabled.value
195 }
196
197 pub fn field(&self) -> &Field<bool> {
199 &self.enabled
200 }
201
202 pub fn copy(&self, out: &mut dyn Write, text: &str) -> Result<(), ClipboardError> {
204 if !self.enabled.value {
205 return Err(ClipboardError::Unsupported(self.enabled.reason.clone()));
206 }
207 let sequence = osc52(text)?;
208 out.write_all(sequence.as_bytes())
209 .and_then(|()| out.flush())
210 .map_err(ClipboardError::Io)
211 }
212}
213
214pub fn osc52(text: &str) -> Result<String, ClipboardError> {
217 if text.len() > MAX_BYTES {
218 return Err(ClipboardError::TooLarge { bytes: text.len() });
219 }
220 Ok(format!("\x1b]52;c;{}\x07", base64(text.as_bytes())))
221}
222
223fn base64(bytes: &[u8]) -> String {
225 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
227 for chunk in bytes.chunks(3) {
228 let n = (u32::from(chunk[0]) << 16)
229 | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
230 | u32::from(*chunk.get(2).unwrap_or(&0));
231 for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() {
232 if i <= chunk.len() {
233 out.push(ALPHABET[(n >> shift) as usize & 63] as char);
234 } else {
235 out.push('=');
236 }
237 }
238 }
239 out
240}
241
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
247pub enum CopyFormat {
248 #[default]
250 Text,
251 Csv,
253 Json,
256}
257
258impl CopyFormat {
259 pub fn parse(name: &str) -> Option<CopyFormat> {
261 match name.trim().to_ascii_lowercase().as_str() {
262 "text" | "tsv" | "plain" => Some(CopyFormat::Text),
263 "csv" => Some(CopyFormat::Csv),
264 "json" => Some(CopyFormat::Json),
265 _ => None,
266 }
267 }
268
269 pub fn name(self) -> &'static str {
270 match self {
271 CopyFormat::Text => "text",
272 CopyFormat::Csv => "csv",
273 CopyFormat::Json => "json",
274 }
275 }
276
277 #[must_use]
279 pub fn next(self) -> CopyFormat {
280 match self {
281 CopyFormat::Text => CopyFormat::Csv,
282 CopyFormat::Csv => CopyFormat::Json,
283 CopyFormat::Json => CopyFormat::Text,
284 }
285 }
286
287 pub fn row<H: AsRef<str>, C: AsRef<str>>(self, headers: &[H], cells: &[C]) -> String {
302 match self {
303 CopyFormat::Text => join(cells.iter().map(|c| c.as_ref().to_string()), "\t"),
304 CopyFormat::Csv => join(cells.iter().map(|c| csv_field(c.as_ref())), ","),
305 CopyFormat::Json if headers.is_empty() => format!(
306 "[{}]",
307 join(cells.iter().map(|c| json_string(c.as_ref())), ", ")
308 ),
309 CopyFormat::Json => {
310 let fields = cells.iter().enumerate().map(|(i, cell)| {
311 let key = headers
312 .get(i)
313 .map_or_else(|| format!("column {}", i + 1), |h| h.as_ref().to_string());
314 format!("{}: {}", json_string(&key), json_string(cell.as_ref()))
315 });
316 format!("{{{}}}", join(fields, ", "))
317 }
318 }
319 }
320
321 pub fn cell(self, cell: &str) -> String {
331 match self {
332 CopyFormat::Text => cell.to_string(),
333 CopyFormat::Csv => csv_field(cell),
334 CopyFormat::Json => json_string(cell),
335 }
336 }
337}
338
339fn join(parts: impl Iterator<Item = String>, separator: &str) -> String {
340 parts.collect::<Vec<_>>().join(separator)
341}
342
343fn csv_field(cell: &str) -> String {
345 if cell.contains([',', '"', '\n', '\r']) {
346 format!("\"{}\"", cell.replace('"', "\"\""))
347 } else {
348 cell.to_string()
349 }
350}
351
352pub fn json_string(text: &str) -> String {
354 let mut out = String::with_capacity(text.len() + 2);
355 out.push('"');
356 for c in text.chars() {
357 match c {
358 '"' => out.push_str("\\\""),
359 '\\' => out.push_str("\\\\"),
360 '\n' => out.push_str("\\n"),
361 '\r' => out.push_str("\\r"),
362 '\t' => out.push_str("\\t"),
363 c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
364 c => out.push(c),
365 }
366 }
367 out.push('"');
368 out
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::capabilities::MapEnvironment;
375
376 #[test]
377 fn base64_pads_like_the_standard() {
378 assert_eq!(base64(b""), "");
379 assert_eq!(base64(b"f"), "Zg==");
380 assert_eq!(base64(b"fo"), "Zm8=");
381 assert_eq!(base64(b"foo"), "Zm9v");
382 assert_eq!(base64(b"foob"), "Zm9vYg==");
383 assert_eq!(base64("é✓".as_bytes()), "w6ninJM=");
384 }
385
386 #[test]
387 fn detection_needs_a_terminal_and_a_known_one() {
388 let tty = MapEnvironment::tty();
389 assert!(!detect(&tty).value, "an unknown terminal");
390 assert!(detect(&tty.clone().var("TERM_PROGRAM", "WezTerm")).value);
391 assert!(detect(&tty.clone().var("WT_SESSION", "x")).value);
392 assert!(detect(&tty.clone().var("TERM", "foot-extra")).value);
393 let kitty = tty.clone().var("TERM", "xterm-kitty");
394 assert!(!detect(&kitty.clone().var("TMUX", "/tmp/t")).value);
395 assert!(!detect(&kitty.clone().terminal(false)).value);
396 assert!(!detect(&MapEnvironment::tty().var("TERM", "dumb")).value);
397 }
398
399 #[test]
400 fn the_variable_overrides_detection_both_ways() {
401 let off = MapEnvironment::tty()
402 .var("TERM", "xterm-kitty")
403 .var(CLIPBOARD_VAR, "0");
404 let field = detect(&off);
405 assert!(!field.value);
406 assert_eq!(field.origin, Origin::Environment(CLIPBOARD_VAR.into()));
407 assert!(detect(&MapEnvironment::tty().var(CLIPBOARD_VAR, "yes")).value);
409 let piped = detect(&MapEnvironment::new().var(CLIPBOARD_VAR, "yes"));
412 assert!(!piped.value);
413 assert_eq!(piped.reason, "not a terminal");
414 assert!(!detect(&MapEnvironment::tty().var(CLIPBOARD_VAR, "maybe")).value);
416 }
417
418 #[test]
419 fn a_disabled_or_oversized_copy_writes_nothing() {
420 let mut out = Vec::new();
421 let error = Clipboard::forced(false).copy(&mut out, "x").unwrap_err();
422 assert!(matches!(error, ClipboardError::Unsupported(_)));
423 let big = "x".repeat(MAX_BYTES + 1);
424 let error = Clipboard::forced(true).copy(&mut out, &big).unwrap_err();
425 assert!(matches!(error, ClipboardError::TooLarge { .. }));
426 assert!(out.is_empty());
427 }
428
429 #[test]
430 fn rows_without_headings_are_arrays() {
431 let none: [&str; 0] = [];
432 assert_eq!(CopyFormat::Json.row(&none, &["a", "b"]), r#"["a", "b"]"#);
433 assert_eq!(
434 CopyFormat::Json.row(&["k"], &["a", "b"]),
435 r#"{"k": "a", "column 2": "b"}"#
436 );
437 assert_eq!(CopyFormat::Csv.row(&none, &["a\nb", "c"]), "\"a\nb\",c");
438 assert_eq!(CopyFormat::Json.cell("\u{1b}"), r#""\u001b""#);
439 }
440}