libtmux/server/keys.rs
1//! Key bindings read as fields through `list-keys -F`.
2
3use super::Server;
4use crate::formats::{
5 FormatCodecError, FormatCodecErrorKind, FormatCodecPhase, TmuxText, TransportDialect,
6};
7use crate::version::since::LIST_KEYS_FORMAT;
8use crate::{Command, Error, ListingDecodeError};
9
10/// The fields [`TEMPLATE`] renders, in order.
11const FIELDS: [&str; 5] = [
12 "key_table",
13 "key_string",
14 "key_repeat",
15 "key_note",
16 "key_command",
17];
18
19/// `list-keys -F` template for [`FIELDS`], framed as a format plan's is.
20const TEMPLATE: &str =
21 "#{q:key_table}=#{q:key_string}=#{key_repeat}=#{q:key_note}=#{q:key_command}=";
22
23/// One key binding, as tmux holds it.
24///
25/// `bind-key -n` is `-T root`, so [`Self::table`] answers it. The command is
26/// the text a `bind-key` line in a configuration file carries, with `\;`
27/// between commands, and is not parsed further. That is argument syntax: as
28/// the single command string [`Server::bind_key`] takes, `\;` is a literal
29/// semicolon rather than a separator.
30///
31/// # Examples
32///
33/// ```
34/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
35/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
36/// # runtime.block_on(async {
37/// let guard = libtmux::test::TestServer::new().await?;
38/// let server = guard.server();
39/// server.bind_key("prefix", "Y", "display-message hello").await?;
40///
41/// let version = server.capabilities().await?.tmux_version().clone();
42/// if version.has_behavior(&libtmux::since::LIST_KEYS_FORMAT) {
43/// let bindings = server.typed_key_bindings(Some("prefix")).await?;
44/// let bound = bindings
45/// .iter()
46/// .find(|binding| binding.key() == "Y")
47/// .ok_or("the binding is listed")?;
48/// assert_eq!(bound.command(), "display-message hello");
49/// assert!(!bound.repeats());
50/// assert_eq!(bound.note(), None);
51/// }
52///
53/// guard.shutdown().await?;
54/// # Ok::<(), Box<dyn std::error::Error>>(())
55/// # })?;
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Clone, Debug, Eq, Hash, PartialEq)]
60pub struct KeyBinding {
61 table: TmuxText,
62 key: TmuxText,
63 command: TmuxText,
64 note: Option<TmuxText>,
65 repeats: bool,
66}
67
68impl KeyBinding {
69 /// Return the key table, such as `prefix`, `root` or `copy-mode-vi`.
70 #[must_use]
71 pub const fn table(&self) -> &TmuxText {
72 &self.table
73 }
74
75 /// Return the key as tmux names it, such as `C-b` or `"`, unquoted.
76 #[must_use]
77 pub const fn key(&self) -> &TmuxText {
78 &self.key
79 }
80
81 /// Return the bound command list, as a `bind-key` line renders it.
82 #[must_use]
83 pub const fn command(&self) -> &TmuxText {
84 &self.command
85 }
86
87 /// Return the note `bind-key -N` attached, if any.
88 ///
89 /// An empty note reads as none: tmux reports both as an empty value.
90 #[must_use]
91 pub const fn note(&self) -> Option<&TmuxText> {
92 self.note.as_ref()
93 }
94
95 /// Report whether the binding repeats, `bind-key -r`.
96 #[must_use]
97 pub const fn repeats(&self) -> bool {
98 self.repeats
99 }
100}
101
102/// Decode `list-keys -F` output rendered with [`TEMPLATE`].
103///
104/// A row that does not frame, or whose repeat flag is neither `0` nor `1`,
105/// fails the whole listing rather than being skipped.
106fn parse(stdout: &[u8]) -> Result<Vec<KeyBinding>, FormatCodecError> {
107 // `list-keys -F` arrived in 3.7, after the `vis` releases.
108 crate::formats::split_quoted_rows(stdout, FIELDS, TransportDialect::RawQ)?
109 .into_iter()
110 .enumerate()
111 .map(|(row, [table, key, repeat, note, command])| {
112 let repeats = match repeat.as_slice() {
113 b"0" => false,
114 b"1" => true,
115 _ => {
116 return Err(FormatCodecError::uncatalogued(
117 FormatCodecErrorKind::InvalidValue,
118 FormatCodecPhase::Decode,
119 row,
120 2,
121 FIELDS[2],
122 None,
123 ));
124 }
125 };
126 Ok(KeyBinding {
127 table: TmuxText::from(table),
128 key: TmuxText::from(key),
129 command: TmuxText::from(command),
130 note: (!note.is_empty()).then(|| TmuxText::from(note)),
131 repeats,
132 })
133 })
134 .collect()
135}
136
137impl Server {
138 /// List key bindings as fields: table, key, command, note and repeat.
139 ///
140 /// `table` narrows the listing to one key table. A table with no bindings
141 /// lists empty, since tmux keeps no empty table to refuse.
142 ///
143 /// Every table is fetched and the narrowing done here. tmux 3.7 through
144 /// 3.7c send a listing of exactly one binding to the message log instead
145 /// of printing it, so `list-keys -T` on a table holding one binding
146 /// answers with nothing. Across every table, that happens only on a
147 /// server with a single binding left, which lists none on those releases.
148 ///
149 /// # Errors
150 ///
151 /// Returns [`Error::UnsupportedCapability`] below tmux 3.7, which has no
152 /// `list-keys -F`; [`Self::key_bindings`] reads the `bind-key` lines
153 /// those releases print. Returns [`Error::DecodeListing`] when a row does
154 /// not decode, and an error when tmux cannot be reached or refuses the
155 /// listing.
156 pub async fn typed_key_bindings(&self, table: Option<&str>) -> Result<Vec<KeyBinding>, Error> {
157 self.require("list-keys formats", LIST_KEYS_FORMAT).await?;
158
159 let result = self
160 .cmd(Command::new("list-keys").arg("-F").arg(TEMPLATE))
161 .await?;
162 if !result.success() {
163 return Err(Error::from_refused_result("list-keys", &result, None));
164 }
165
166 let bindings = parse(result.stdout()).map_err(|detail| Error::DecodeListing {
167 list_command: "list-keys",
168 detail: ListingDecodeError::new(detail),
169 })?;
170 Ok(match table {
171 Some(table) => bindings
172 .into_iter()
173 .filter(|binding| binding.table == table)
174 .collect(),
175 None => bindings,
176 })
177 }
178}
179
180/// Decode `list-keys -F` output, for fuzzing only.
181///
182/// Not a supported API: it lets a fuzzer reach the decoder without making it
183/// public, behind a feature no release turns on.
184#[cfg(feature = "unstable-fuzzing")]
185#[doc(hidden)]
186pub fn __fuzz_parse_key_bindings(stdout: &[u8]) {
187 let _ = parse(stdout);
188}
189
190#[cfg(test)]
191mod tests {
192 use super::{KeyBinding, parse};
193 use crate::TmuxText;
194 use crate::formats::FormatCodecErrorKind;
195
196 fn binding(table: &str, key: &str, command: &str) -> KeyBinding {
197 KeyBinding {
198 table: TmuxText::from(table),
199 key: TmuxText::from(key),
200 command: TmuxText::from(command),
201 note: None,
202 repeats: false,
203 }
204 }
205
206 /// tmux 3.7c's output for two bindings; its `#{q:}` leaves LF bare.
207 #[test]
208 fn rows_decode_escapes_and_keep_bare_newlines() {
209 let stdout = br#"solo=M-\'=0==display-message\ a\=b\ \\\;\ display-message\ c=
210sp\ ace=\"=1=a
211note=display-message\ \"two\ words\"=
212"#;
213
214 let solo = binding("solo", "M-'", r"display-message a=b \; display-message c");
215 let mut spaced = binding("sp ace", "\"", "display-message \"two words\"");
216 spaced.note = Some(TmuxText::from("a\nnote"));
217 spaced.repeats = true;
218
219 assert_eq!(parse(stdout), Ok(vec![solo, spaced]));
220 assert_eq!(parse(b""), Ok(Vec::new()));
221 }
222
223 /// Each malformed shape fails the listing; none yields a shorter one.
224 #[test]
225 fn a_row_that_does_not_frame_fails_the_listing() {
226 let good = b"root=x=0==send-keys=\n".as_slice();
227 for (stdout, kind) in [
228 (
229 b"root=x=0==send-keys".as_slice(),
230 FormatCodecErrorKind::MissingFieldTerminator,
231 ),
232 (b"root=x=0==send-keys=", FormatCodecErrorKind::MissingRowLf),
233 (
234 b"root=x=0==send-keys=x\n",
235 FormatCodecErrorKind::UnexpectedRowTerminator,
236 ),
237 (
238 b"root=\\x=0==send-keys=\n",
239 FormatCodecErrorKind::InvalidEscape,
240 ),
241 (b"root=x\\", FormatCodecErrorKind::DanglingEscape),
242 (
243 b"root=x\0=0==send-keys=\n",
244 FormatCodecErrorKind::EmbeddedNul,
245 ),
246 (
247 b"root=x=2==send-keys=\n",
248 FormatCodecErrorKind::InvalidValue,
249 ),
250 ] {
251 let listing = [good, stdout].concat();
252 let error = parse(&listing).expect_err("a malformed row fails");
253 assert_eq!(error.kind(), kind, "{:?}", String::from_utf8_lossy(stdout));
254 assert_eq!(error.row(), Some(1));
255 }
256 }
257}