xterm_query/
lib.rs

1mod error;
2
3use {nix::errno::Errno, std::os::fd::BorrowedFd};
4
5pub use error::*;
6
7/// Query the xterm interface, assuming the terminal is in raw mode
8/// (or we would block waiting for a newline).
9pub fn query<MS: Into<u64>>(query: &str, timeout_ms: MS) -> Result<String, XQError> {
10    // I'll use <const N: usize = 100> when default values for const generics
11    // are stabilized for enough rustc versions
12    // See https://github.com/rust-lang/rust/issues/44580
13    const N: usize = 100;
14    let mut response = [0; N];
15    let n = query_buffer(query, &mut response, timeout_ms.into())?;
16    let s = std::str::from_utf8(&response[..n])?;
17    Ok(s.to_string())
18}
19/// Query the xterm interface for an OSC sequence, assuming the terminal is in raw mode
20/// (or we would block waiting for a newline).
21///
22/// The query should be a proper OSC sequence (ie already wrapped, eg "\x1b]11;?\x07")
23/// as you want it to be sent to stdout but the answer is only the part after the C0 (ESC)
24/// and before the OSC terminator (BEL or ESC).
25pub fn query_osc<MS: Into<u64>>(query: &str, timeout_ms: MS) -> Result<String, XQError> {
26    // I'll use <const N: usize = 100> when default values for const generics
27    // are stabilized for enough rustc versions
28    // See https://github.com/rust-lang/rust/issues/44580
29    const N: usize = 100;
30    let mut response = [0; N];
31    let resp = query_osc_buffer(query, &mut response, timeout_ms.into())?;
32    let s = std::str::from_utf8(resp)?;
33    Ok(s.to_string())
34}
35
36/// Query the xterm interface, assuming the terminal is in raw mode
37/// (or we would block waiting for a newline).
38///
39/// Return the number of bytes read.
40#[cfg(unix)]
41pub fn query_buffer<MS: Into<u64>>(
42    query: &str,
43    buffer: &mut [u8],
44    timeout_ms: MS,
45) -> Result<usize, XQError> {
46    use std::{
47        fs::File,
48        io::{self, Read, Write},
49        os::fd::AsFd,
50    };
51    let stdout = io::stdout();
52    let mut stdout = stdout.lock();
53    write!(stdout, "{}", query)?;
54    stdout.flush()?;
55    let mut stdin = File::open("/dev/tty")?;
56    let stdin_fd = stdin.as_fd();
57    match wait_for_input(stdin_fd, timeout_ms) {
58        Ok(0) => Err(XQError::Timeout),
59        Ok(_) => {
60            let bytes_written = stdin.read(buffer)?;
61            Ok(bytes_written)
62        }
63        Err(e) => Err(XQError::IO(e.into())),
64    }
65}
66
67/// Query the xterm interface for an OSC response, assuming the terminal is in raw mode
68/// (or we would block waiting for a newline).
69///
70/// The provided query should be a proper OSC sequence (ie already wrapped, eg "\x1b]11;?\x07")
71///
72/// Return a slice of the buffer containing the response. This slice excludes
73/// - the response start (ESC) and everything before
74/// - the response end (ESC or BEL) and everything after
75///
76/// OSC sequence:
77///  <https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences>
78#[cfg(unix)]
79pub fn query_osc_buffer<'b, MS: Into<u64> + Copy>(
80    query: &str,
81    buffer: &'b mut [u8],
82    timeout_ms: MS,
83) -> Result<&'b [u8], XQError> {
84    use std::{
85        fs::File,
86        io::{self, Read, Write},
87        os::fd::AsFd,
88    };
89    const ESC: char = '\x1b';
90    const BEL: char = '\x07';
91
92    // Do some casing based on the terminal
93    let term = std::env::var("TERM").map_err(|_| XQError::Unsupported)?;
94    if term == "dumb" {
95        return Err(XQError::Unsupported);
96    }
97    let is_screen = term.starts_with("screen");
98
99    let stdout = io::stdout();
100    let mut stdout = stdout.lock();
101
102    // Running under GNU Screen, the commands need to be "escaped",
103    // apparently.  We wrap them in a "Device Control String", which
104    // will make Screen forward the contents uninterpreted.
105    if is_screen {
106        write!(stdout, "{ESC}P")?;
107    }
108
109    write!(stdout, "{}", query)?;
110    // Ask for a Status Report as a "fence". Almost all terminals will
111    // support that command, even if they don't support returning the
112    // background color, so we can detect "not supported" by the
113    // Status Report being answered first.
114    write!(stdout, "{ESC}[5n")?;
115
116    if is_screen {
117        write!(stdout, "{ESC}\\")?;
118    }
119
120    stdout.flush()?;
121    let mut stdin = File::open("/dev/tty")?;
122    let mut osc_start_idx = None;
123    let mut osc_end_idx = None;
124    let mut bytes_written = 0;
125    while bytes_written < buffer.len() {
126        let stdin_fd = stdin.as_fd();
127        match wait_for_input(stdin_fd, timeout_ms) {
128            Ok(0) => {
129                return Err(XQError::Timeout);
130            }
131            Ok(_) => {
132                let bytes_read = stdin.read(&mut buffer[bytes_written..])?;
133                if bytes_read == 0 {
134                    return Err(XQError::NotAnOSCResponse); // EOF
135                }
136                // the sequence must start with a ESC (27) and end either with a ESC or BEL (7)
137                // then, we'll get an 'n' back from the "fence"
138                for i in bytes_written..bytes_written + bytes_read {
139                    let b = buffer[i];
140                    match osc_start_idx {
141                        None => {
142                            if b == ESC as u8 {
143                                osc_start_idx = Some(i);
144                            }
145                        }
146                        Some(start_idx) => {
147                            if b == ESC as u8 || b == BEL as u8 {
148                                if osc_end_idx.is_none() {
149                                    osc_end_idx = Some(i);
150                                }
151                            } else if b == b'n' {
152                                match osc_end_idx {
153                                    None => return Err(XQError::NotAnOSCResponse),
154                                    Some(end_idx) => {
155                                        return Ok(&buffer[start_idx + 1..=end_idx]);
156                                    }
157                                }
158                            }
159                        }
160                    }
161                }
162                bytes_written += bytes_read;
163            }
164            Err(e) => {
165                return Err(XQError::IO(e.into()));
166            }
167        }
168    }
169    Err(XQError::BufferOverflow)
170}
171
172#[cfg(not(unix))]
173pub fn query_buffer(_query: &str, _buffer: &mut [u8], _timeout_ms: u64) -> Result<usize, XQError> {
174    Err(XQError::Unsupported)
175}
176
177#[cfg(not(target_os = "macos"))]
178fn wait_for_input<MS: Into<u64>>(fd: BorrowedFd<'_>, timeout_ms: MS) -> Result<i32, Errno> {
179    use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
180
181    let poll_fd = PollFd::new(fd, PollFlags::POLLIN);
182    let timeout = PollTimeout::try_from(timeout_ms.into()).map_err(|_| Errno::EOVERFLOW)?;
183
184    poll(&mut [poll_fd], timeout)
185}
186
187// On MacOS, we need to use the `select` instead of `poll` because it doesn't support poll with tty:
188//
189// https://github.com/tokio-rs/mio/issues/1377
190#[cfg(target_os = "macos")]
191fn wait_for_input<MS: Into<u64>>(fd: BorrowedFd<'_>, timeout_ms: MS) -> Result<i32, Errno> {
192    use {
193        nix::sys::{
194            select::{select, FdSet},
195            time::TimeVal,
196        },
197        std::{os::fd::AsRawFd, time::Duration},
198    };
199    let mut fd_set = FdSet::new();
200    fd_set.insert(fd);
201    let mut dur =  Duration::from_millis(timeout_ms.into());
202    let timeout_s = dur.as_secs() as _;
203    let timeout_us = dur.subsec_micros() as _;
204    let mut tv = TimeVal::new(timeout_s, timeout_us);
205
206    select(
207        fd.as_raw_fd() + 1,
208        Some(&mut fd_set),
209        None,
210        None,
211        Some(&mut tv),
212    )
213}