Skip to main content

xterm_query/
lib.rs

1//! Query the terminal by writing an escape sequence and reading the reply.
2//!
3//! The terminal must be in raw mode (otherwise reads block on a newline), and
4//! the query should be issued when nothing else is reading terminal input.
5//!
6//! The provided example in examples/kitty demonstrates querying the terminal to
7//! know whether the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/)
8//! is supported, and manages entering and leaving raw mode.
9//!
10//! # Platform support
11//!
12//! This crate supports Linux, MacOS, and Windows. When a platform is not supported, the query
13//! functions return `XQError::Unsupported`.
14//!
15//! Unix reads the reply from `/dev/tty` using `poll`/`select`. Windows reads it
16//! from the console input (`CONIN$`) after switching it to
17//! `ENABLE_VIRTUAL_TERMINAL_INPUT` for the duration of the query.
18//!
19//! ## Windows limitation
20//!
21//! The Windows wait wakes on any console input event, including focus, mouse,
22//! and buffer-resize events, which under VT-input mode may be delivered as
23//! escape sequences. If such an event arrives in the query window, the reply
24//! can be wrong or empty, so callers should treat an unparsable reply as
25//! "unsupported" and degrade gracefully. Issue queries while the terminal is
26//! otherwise quiet (e.g. at startup, before an input loop begins).
27
28mod error;
29
30pub use error::*;
31
32#[cfg(windows)]
33mod win;
34#[cfg(windows)]
35pub use win::{query_buffer, query_osc_buffer};
36
37/// Query the xterm interface, assuming the terminal is in raw mode
38/// (or we would block waiting for a newline).
39pub fn query<MS: Into<u64>>(query: &str, timeout_ms: MS) -> Result<String, XQError> {
40    // I'll use <const N: usize = 100> when default values for const generics
41    // are stabilized for enough rustc versions
42    // See https://github.com/rust-lang/rust/issues/44580
43    const N: usize = 100;
44    let mut response = [0; N];
45    let n = query_buffer(query, &mut response, timeout_ms.into())?;
46    let s = std::str::from_utf8(&response[..n])?;
47    Ok(s.to_string())
48}
49/// Query the xterm interface for an OSC sequence, assuming the terminal is in raw mode
50/// (or we would block waiting for a newline).
51///
52/// The query should be a proper OSC sequence (ie already wrapped, eg "\x1b]11;?\x07")
53/// as you want it to be sent to stdout but the answer is only the part after the C0 (ESC)
54/// and before the OSC terminator (BEL or ESC).
55pub fn query_osc<MS: Into<u64>>(query: &str, timeout_ms: MS) -> Result<String, XQError> {
56    // I'll use <const N: usize = 100> when default values for const generics
57    // are stabilized for enough rustc versions
58    // See https://github.com/rust-lang/rust/issues/44580
59    const N: usize = 100;
60    let mut response = [0; N];
61    let resp = query_osc_buffer(query, &mut response, timeout_ms.into())?;
62    let s = std::str::from_utf8(resp)?;
63    Ok(s.to_string())
64}
65
66/// Outcome of scanning a (partial) buffer for an OSC response framed as
67/// `ESC ... (ESC|BEL)`, followed by the `n` of the DSR ("fence") reply.
68///
69/// The fence (`ESC[5n` -> `ESC[0n`) lets us detect terminals that don't
70/// support the query: they answer the Status Report first, so we meet the
71/// `n` before ever seeing a complete OSC.
72pub(crate) enum OscScan {
73    /// Not enough bytes yet; read more.
74    NeedMore,
75    /// A complete OSC response was found. `start`/`end` are byte indices into
76    /// the scanned buffer; the payload is `buf[start..=end]` (the leading ESC
77    /// is excluded, the terminating ESC/BEL is included, matching the
78    /// historical Unix behavior).
79    Found { start: usize, end: usize },
80    /// The fence answered before a complete OSC: not an OSC response.
81    NotOsc,
82}
83
84/// Scan a buffer for an OSC response. Pure and platform-independent so it can
85/// be shared by the Unix and Windows backends and unit-tested anywhere.
86pub(crate) fn scan_osc(buf: &[u8]) -> OscScan {
87    const ESC: u8 = 0x1b;
88    const BEL: u8 = 0x07;
89    let mut osc_start: Option<usize> = None;
90    let mut osc_end: Option<usize> = None;
91    for (i, &b) in buf.iter().enumerate() {
92        match osc_start {
93            None => {
94                if b == ESC {
95                    osc_start = Some(i);
96                }
97            }
98            Some(start) => {
99                if osc_end.is_none() && (b == ESC || b == BEL) {
100                    osc_end = Some(i);
101                } else if i >= 3
102                    && buf[i - 3] == ESC
103                    && buf[i - 2] == b'['
104                    && buf[i - 1] == b'0'
105                    && b == b'n'
106                {
107                    return match osc_end {
108                        None => OscScan::NotOsc,
109                        Some(end) => OscScan::Found {
110                            start: start + 1,
111                            end,
112                        },
113                    };
114                }
115            }
116        }
117    }
118    OscScan::NeedMore
119}
120
121#[cfg(unix)]
122use {nix::errno::Errno, std::os::fd::BorrowedFd};
123
124/// Query the xterm interface, assuming the terminal is in raw mode
125/// (or we would block waiting for a newline).
126///
127/// Return the number of bytes read.
128#[cfg(unix)]
129pub fn query_buffer<MS: Into<u64>>(
130    query: &str,
131    buffer: &mut [u8],
132    timeout_ms: MS,
133) -> Result<usize, XQError> {
134    use std::{
135        fs::File,
136        io::{self, Read, Write},
137        os::fd::AsFd,
138    };
139    let stdout = io::stdout();
140    let mut stdout = stdout.lock();
141    write!(stdout, "{}", query)?;
142    stdout.flush()?;
143    let mut stdin = File::open("/dev/tty")?;
144    let stdin_fd = stdin.as_fd();
145    match wait_for_input(stdin_fd, timeout_ms) {
146        Ok(0) => Err(XQError::Timeout),
147        Ok(_) => {
148            let bytes_written = stdin.read(buffer)?;
149            Ok(bytes_written)
150        }
151        Err(e) => Err(XQError::IO(e.into())),
152    }
153}
154
155/// Query the xterm interface for an OSC response, assuming the terminal is in raw mode
156/// (or we would block waiting for a newline).
157///
158/// The provided query should be a proper OSC sequence (ie already wrapped, eg "\x1b]11;?\x07")
159///
160/// Return a slice of the buffer containing the response. This slice excludes
161/// - the response start (ESC) and everything before
162/// - the response end (ESC or BEL) and everything after
163///
164/// OSC sequence:
165///  <https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences>
166#[cfg(unix)]
167pub fn query_osc_buffer<'b, MS: Into<u64> + Copy>(
168    query: &str,
169    buffer: &'b mut [u8],
170    timeout_ms: MS,
171) -> Result<&'b [u8], XQError> {
172    use std::{
173        fs::File,
174        io::{self, Read, Write},
175        os::fd::AsFd,
176    };
177    const ESC: char = '\x1b';
178
179    // Do some casing based on the terminal
180    let term = std::env::var("TERM").map_err(|_| XQError::Unsupported)?;
181    if term == "dumb" {
182        return Err(XQError::Unsupported);
183    }
184    let is_screen = term.starts_with("screen");
185
186    let stdout = io::stdout();
187    let mut stdout = stdout.lock();
188
189    // Running under GNU Screen, the commands need to be "escaped",
190    // apparently.  We wrap them in a "Device Control String", which
191    // will make Screen forward the contents uninterpreted.
192    if is_screen {
193        write!(stdout, "{ESC}P")?;
194    }
195
196    write!(stdout, "{}", query)?;
197    // Ask for a Status Report as a "fence". Almost all terminals will
198    // support that command, even if they don't support returning the
199    // background color, so we can detect "not supported" by the
200    // Status Report being answered first.
201    write!(stdout, "{ESC}[5n")?;
202
203    if is_screen {
204        write!(stdout, "{ESC}\\")?;
205    }
206
207    stdout.flush()?;
208    let mut stdin = File::open("/dev/tty")?;
209    let mut filled = 0;
210    while filled < buffer.len() {
211        let stdin_fd = stdin.as_fd();
212        match wait_for_input(stdin_fd, timeout_ms) {
213            Ok(0) => {
214                return Err(XQError::Timeout);
215            }
216            Ok(_) => {
217                let bytes_read = stdin.read(&mut buffer[filled..])?;
218                if bytes_read == 0 {
219                    return Err(XQError::NotAnOSCResponse); // EOF
220                }
221                filled += bytes_read;
222                match scan_osc(&buffer[..filled]) {
223                    OscScan::Found { start, end } => return Ok(&buffer[start..=end]),
224                    OscScan::NotOsc => return Err(XQError::NotAnOSCResponse),
225                    OscScan::NeedMore => {}
226                }
227            }
228            Err(e) => {
229                return Err(XQError::IO(e.into()));
230            }
231        }
232    }
233    Err(XQError::BufferOverflow)
234}
235
236#[cfg(not(any(unix, windows)))]
237pub fn query_buffer<MS: Into<u64>>(
238    _query: &str,
239    _buffer: &mut [u8],
240    _timeout_ms: MS,
241) -> Result<usize, XQError> {
242    Err(XQError::Unsupported)
243}
244
245#[cfg(not(any(unix, windows)))]
246pub fn query_osc_buffer<'b, MS: Into<u64> + Copy>(
247    _query: &str,
248    _buffer: &'b mut [u8],
249    _timeout_ms: MS,
250) -> Result<&'b [u8], XQError> {
251    Err(XQError::Unsupported)
252}
253
254#[cfg(all(unix, not(target_os = "macos")))]
255fn wait_for_input<MS: Into<u64>>(fd: BorrowedFd<'_>, timeout_ms: MS) -> Result<i32, Errno> {
256    use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
257
258    let poll_fd = PollFd::new(fd, PollFlags::POLLIN);
259    let timeout = PollTimeout::try_from(timeout_ms.into()).map_err(|_| Errno::EOVERFLOW)?;
260
261    poll(&mut [poll_fd], timeout)
262}
263
264// On MacOS, we need to use the `select` instead of `poll` because it doesn't support poll with tty:
265//
266// https://github.com/tokio-rs/mio/issues/1377
267#[cfg(all(unix, target_os = "macos"))]
268fn wait_for_input<MS: Into<u64>>(fd: BorrowedFd<'_>, timeout_ms: MS) -> Result<i32, Errno> {
269    use {
270        nix::sys::{
271            select::{select, FdSet},
272            time::TimeVal,
273        },
274        std::{os::fd::AsRawFd, time::Duration},
275    };
276    let mut fd_set = FdSet::new();
277    fd_set.insert(fd);
278    let dur = Duration::from_millis(timeout_ms.into());
279    let timeout_s = dur.as_secs() as _;
280    let timeout_us = dur.subsec_micros() as _;
281    let mut tv = TimeVal::new(timeout_s, timeout_us);
282
283    select(
284        fd.as_raw_fd() + 1,
285        Some(&mut fd_set),
286        None,
287        None,
288        Some(&mut tv),
289    )
290}
291
292#[cfg(test)]
293mod tests {
294    use super::{scan_osc, OscScan};
295
296    fn found(buf: &[u8]) -> Option<&[u8]> {
297        match scan_osc(buf) {
298            OscScan::Found { start, end } => Some(&buf[start..=end]),
299            _ => None,
300        }
301    }
302
303    #[test]
304    fn bel_terminated_osc_then_fence() {
305        // ESC ] 11 ; rgb:0000/0000/0000 BEL   then DSR reply ESC [ 0 n
306        let buf = b"\x1b]11;rgb:1212/3434/5656\x07\x1b[0n";
307        let payload = found(buf).expect("should find OSC payload");
308        // payload excludes leading ESC, includes terminating BEL
309        assert_eq!(payload, b"]11;rgb:1212/3434/5656\x07");
310    }
311
312    #[test]
313    fn st_terminated_osc_then_fence() {
314        // ST = ESC \ ; the terminating ESC is the OSC end
315        let buf = b"\x1b]11;rgb:1212/3434/5656\x1b\\\x1b[0n";
316        let payload = found(buf).expect("should find OSC payload");
317        assert_eq!(payload, b"]11;rgb:1212/3434/5656\x1b");
318    }
319
320    #[test]
321    fn osc_payload_containing_n_is_found() {
322        // The OSC payload itself contains 'n' (e.g. `]11;none`); the scanner must
323        // not mistake that 'n' for the DSR fence and return NotOsc early.
324        let buf = b"\x1b]11;none\x07\x1b[0n";
325        let payload = found(buf).expect("OSC payload with 'n' should still be found");
326        assert_eq!(payload, b"]11;none\x07");
327    }
328
329    #[test]
330    fn fence_answered_first_is_not_osc() {
331        // Terminal doesn't support the query: only the DSR reply arrives.
332        let buf = b"\x1b[0n";
333        assert!(matches!(scan_osc(buf), OscScan::NotOsc));
334    }
335
336    #[test]
337    fn incomplete_needs_more() {
338        let buf = b"\x1b]11;rgb:1212/34";
339        assert!(matches!(scan_osc(buf), OscScan::NeedMore));
340    }
341
342    #[test]
343    fn empty_needs_more() {
344        assert!(matches!(scan_osc(b""), OscScan::NeedMore));
345    }
346}