Skip to main content

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}