Skip to main content

keymap_core/
legacy.rs

1//! How a chord fares under the legacy 7-bit terminal encoding.
2//!
3//! Terminals that do not implement an enhanced keyboard protocol (the kitty
4//! keyboard protocol, xterm's `modifyOtherKeys`, …) encode keys with the old
5//! C0 control-code scheme, which cannot carry every chord a modern keyboard can
6//! produce. The classic surprises:
7//!
8//! - **`ctrl+shift+<letter>` is the same byte as `ctrl+<letter>`.** A control
9//!   code is `letter & 0x1f` with no bit for Shift, so `ctrl+shift+s` and
10//!   `ctrl+s` are *indistinguishable* — a binding to one silently fires on the
11//!   other. (This is why terminal Vim/Emacs avoid `Ctrl+Shift+*` and lean on
12//!   prefix/leader sequences instead — see `keymap-seq`.)
13//! - **`ctrl+i` ≡ `tab`, `ctrl+m` ≡ `enter`, `ctrl+[` ≡ `esc`.** Same C0 byte.
14//! - **`super`/`cmd` has no legacy encoding at all** — a legacy terminal cannot
15//!   deliver it.
16//!
17//! [`KeyInput::legacy_form`] reports this *structural* fact from the chord
18//! alone — no terminal measurement needed, so it is stable and CI-testable
19//! today. It deliberately does **not** answer "will this key reach the app on
20//! my terminal": Option composing a glyph (`Option+s` → `ß`), the OS eating
21//! `Cmd`, or the `alt`-vs-`Esc` timing ambiguity are environment-dependent
22//! *reachability* questions for the later capability-aware layer. Treat
23//! `legacy_form` as the lower bound (what is lost *no matter the terminal*); the
24//! reachability layer refines it with empirical per-terminal data.
25
26use crate::{Key, KeyInput, Keymap, Modifiers};
27
28/// How a [`KeyInput`] survives the legacy 7-bit C0 terminal encoding,
29/// independent of any enhanced protocol. See the [module docs](self).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LegacyForm {
32    /// Encodable and distinguishable as-is under legacy C0.
33    Representable,
34    /// Loses information under legacy C0 and becomes indistinguishable from this
35    /// other chord (e.g. `ctrl+shift+s` → `ctrl+s`, `ctrl+i` → `tab`). A binding
36    /// to the original silently behaves like the contained chord on legacy
37    /// terminals — bind that chord (or use a `keymap-seq` sequence) instead.
38    CollapsesTo(KeyInput),
39    /// Has no legacy C0 encoding at all (any chord holding `super`/`cmd`); a
40    /// legacy terminal cannot deliver it.
41    Unrepresentable,
42}
43
44impl KeyInput {
45    /// Reports how this chord fares under the legacy 7-bit C0 terminal encoding
46    /// (see [`LegacyForm`] and the [module docs](self)).
47    ///
48    /// This is a pure, terminal-independent structural fact, not a reachability
49    /// verdict. Only the cases that are *certain* regardless of terminal are
50    /// reported as [`CollapsesTo`](LegacyForm::CollapsesTo) /
51    /// [`Unrepresentable`](LegacyForm::Unrepresentable); everything else is
52    /// [`Representable`](LegacyForm::Representable), which means "has a legacy C0
53    /// encoding", not "guaranteed to arrive on every terminal".
54    #[must_use]
55    pub fn legacy_form(&self) -> LegacyForm {
56        // `super`/`cmd` cannot be encoded in legacy C0 under any circumstance.
57        if self.modifiers().contains(Modifiers::SUPER) {
58            return LegacyForm::Unrepresentable;
59        }
60        let collapsed = collapse(self.key(), self.modifiers());
61        if collapsed == *self {
62            LegacyForm::Representable
63        } else {
64            LegacyForm::CollapsesTo(collapsed)
65        }
66    }
67}
68
69/// The chord a legacy C0 terminal actually delivers for `(key, mods)`, for the
70/// cases that are certain. Returns the input unchanged when nothing is lost.
71fn collapse(key: Key, mods: Modifiers) -> KeyInput {
72    if mods.contains(Modifiers::CTRL) {
73        if let Key::Char(c) = key {
74            // `ctrl+i`/`ctrl+m`/`ctrl+[` are the same C0 byte as the named keys.
75            let aliased = match c.to_ascii_lowercase() {
76                'i' => Some(Key::Tab),
77                'm' => Some(Key::Enter),
78                '[' => Some(Key::Esc),
79                _ => None,
80            };
81            if let Some(named) = aliased {
82                // Ctrl and Shift are both absorbed into the single C0 byte; any
83                // Alt (Meta / ESC-prefix) is orthogonal and survives.
84                let rest = mods
85                    .difference(Modifiers::CTRL)
86                    .difference(Modifiers::SHIFT);
87                return KeyInput::new(named, rest);
88            }
89            // `ctrl+shift+<letter>` sends the same byte as `ctrl+<letter>`.
90            if mods.contains(Modifiers::SHIFT) {
91                return KeyInput::new(key, mods.difference(Modifiers::SHIFT));
92            }
93        }
94    }
95    KeyInput::new(key, mods)
96}
97
98/// A bound chord that will not survive a legacy 7-bit (C0) terminal, reported by
99/// [`legacy_lints`]. The variants mirror the two lossy [`LegacyForm`] cases (a
100/// [`Representable`](LegacyForm::Representable) chord produces no lint).
101///
102/// Chords are rendered in canonical form (`KeyInput`'s `Display`), matching how
103/// the config layer names chords in its warnings. This is `#[non_exhaustive]`:
104/// the empirical capability-aware layer may add environment-dependent lint
105/// categories (e.g. "may not be delivered on this terminal") additively.
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum LegacyLint {
109    /// The chord holds `super`/`cmd`: it has no legacy C0 encoding at all, so a
110    /// legacy terminal can never deliver it. See
111    /// [`Unrepresentable`](LegacyForm::Unrepresentable).
112    Unrepresentable {
113        /// The bound chord, in canonical form (e.g. `"super+s"`).
114        chord: String,
115    },
116    /// On a legacy C0 terminal the chord is indistinguishable from another, so a
117    /// binding to it also fires on `collapses_to` (e.g. `ctrl+shift+s` ≡
118    /// `ctrl+s`, `ctrl+i` ≡ `tab`). See [`CollapsesTo`](LegacyForm::CollapsesTo).
119    CollapsesTo {
120        /// The bound chord, in canonical form.
121        chord: String,
122        /// The chord it is indistinguishable from on a legacy terminal, in
123        /// canonical form.
124        collapses_to: String,
125    },
126}
127
128/// Reports every bound chord in `keymap` that will not survive a legacy 7-bit
129/// (C0) terminal, derived purely from [`KeyInput::legacy_form`] — a structural
130/// fact needing no terminal measurement, so this is stable and CI-testable.
131///
132/// This is **opt-in**: nothing computes it unless you call it, and it is wholly
133/// independent of any config-layer warning surface (`keymap-config`'s
134/// `BuildOutput::warnings`), so a caller gating on `warnings.is_empty()` is
135/// unaffected. Pass `out.global()` (or any one layer) after a config load, or any
136/// `Keymap` you built by hand.
137///
138/// The result is sorted by chord (canonical form); [`Keymap::iter`] is itself
139/// unordered, so sorting gives stable, human-readable output. Covers
140/// single-chord bindings only — multi-key sequences are a planned follow-up
141/// (gated on iteration over `SequenceKeymap`).
142#[must_use]
143pub fn legacy_lints<A>(keymap: &Keymap<A>) -> Vec<LegacyLint> {
144    let mut lints: Vec<LegacyLint> = keymap
145        .iter()
146        .filter_map(|(input, _)| match input.legacy_form() {
147            LegacyForm::Representable => None,
148            LegacyForm::Unrepresentable => Some(LegacyLint::Unrepresentable {
149                chord: input.to_string(),
150            }),
151            LegacyForm::CollapsesTo(target) => Some(LegacyLint::CollapsesTo {
152                chord: input.to_string(),
153                collapses_to: target.to_string(),
154            }),
155        })
156        .collect();
157    // Each KeyInput key is unique, so the chord is a total, stable sort key.
158    lints.sort_by(|a, b| lint_chord(a).cmp(lint_chord(b)));
159    lints
160}
161
162/// The canonical chord string a [`LegacyLint`] is about (its sort key).
163fn lint_chord(lint: &LegacyLint) -> &str {
164    match lint {
165        LegacyLint::Unrepresentable { chord } | LegacyLint::CollapsesTo { chord, .. } => chord,
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::{Key, KeyInput, Modifiers};
173
174    fn ctrl(c: char) -> KeyInput {
175        KeyInput::new(Key::Char(c), Modifiers::CTRL)
176    }
177
178    fn ctrl_shift(c: char) -> KeyInput {
179        KeyInput::new(Key::Char(c), Modifiers::CTRL | Modifiers::SHIFT)
180    }
181
182    #[test]
183    fn super_chords_are_unrepresentable() {
184        let cmd_s = KeyInput::new(Key::Char('s'), Modifiers::SUPER);
185        assert_eq!(cmd_s.legacy_form(), LegacyForm::Unrepresentable);
186        // Super dominates even when the rest would otherwise collapse.
187        let cmd_shift_s = KeyInput::new(Key::Char('s'), Modifiers::SUPER | Modifiers::SHIFT);
188        assert_eq!(cmd_shift_s.legacy_form(), LegacyForm::Unrepresentable);
189    }
190
191    #[test]
192    fn ctrl_shift_letter_collapses_to_ctrl_letter() {
193        assert_eq!(
194            ctrl_shift('s').legacy_form(),
195            LegacyForm::CollapsesTo(ctrl('s'))
196        );
197    }
198
199    #[test]
200    fn ctrl_i_m_bracket_alias_named_keys() {
201        assert_eq!(
202            ctrl('i').legacy_form(),
203            LegacyForm::CollapsesTo(KeyInput::new(Key::Tab, Modifiers::NONE))
204        );
205        assert_eq!(
206            ctrl('m').legacy_form(),
207            LegacyForm::CollapsesTo(KeyInput::new(Key::Enter, Modifiers::NONE))
208        );
209        assert_eq!(
210            ctrl('[').legacy_form(),
211            LegacyForm::CollapsesTo(KeyInput::new(Key::Esc, Modifiers::NONE))
212        );
213        // ctrl+shift+i still lands on Tab (shift and ctrl both absorbed).
214        assert_eq!(
215            ctrl_shift('i').legacy_form(),
216            LegacyForm::CollapsesTo(KeyInput::new(Key::Tab, Modifiers::NONE))
217        );
218    }
219
220    #[test]
221    fn plain_and_ctrl_letter_are_representable() {
222        assert_eq!(
223            KeyInput::new(Key::Char('a'), Modifiers::NONE).legacy_form(),
224            LegacyForm::Representable
225        );
226        // ctrl+s (no shift) is the canonical C0 byte itself.
227        assert_eq!(ctrl('s').legacy_form(), LegacyForm::Representable);
228    }
229
230    #[test]
231    fn named_function_and_arrow_keys_are_representable() {
232        for key in [
233            Key::Tab,
234            Key::Enter,
235            Key::Esc,
236            Key::Up,
237            Key::F(1),
238            Key::Home,
239        ] {
240            assert_eq!(
241                KeyInput::new(key, Modifiers::NONE).legacy_form(),
242                LegacyForm::Representable,
243                "{key:?} should be representable"
244            );
245        }
246        // shift+tab is a distinct legacy sequence (BackTab), still representable.
247        assert_eq!(
248            KeyInput::new(Key::Tab, Modifiers::SHIFT).legacy_form(),
249            LegacyForm::Representable
250        );
251    }
252
253    fn lint_map(chords: &[KeyInput]) -> Keymap<u8> {
254        let mut map = Keymap::new();
255        for (i, &c) in chords.iter().enumerate() {
256            map.bind(c, u8::try_from(i).unwrap());
257        }
258        map
259    }
260
261    #[test]
262    fn legacy_lints_empty_map_is_empty() {
263        assert_eq!(legacy_lints(&Keymap::<u8>::new()), vec![]);
264    }
265
266    #[test]
267    fn legacy_lints_classify_each_arm_with_canonical_strings() {
268        // ctrl+s = Representable (no lint); cmd+s = Unrepresentable;
269        // ctrl+shift+s = CollapsesTo ctrl+s; ctrl+i = CollapsesTo tab.
270        let map = lint_map(&[
271            KeyInput::new(Key::Char('s'), Modifiers::CTRL),
272            KeyInput::new(Key::Char('s'), Modifiers::SUPER),
273            KeyInput::new(Key::Char('s'), Modifiers::CTRL | Modifiers::SHIFT),
274            ctrl('i'),
275        ]);
276        // Sorted by chord: "ctrl+i", "ctrl+shift+s", "super+s" ("ctrl+s" omitted).
277        assert_eq!(
278            legacy_lints(&map),
279            vec![
280                LegacyLint::CollapsesTo {
281                    chord: "ctrl+i".to_string(),
282                    collapses_to: "tab".to_string(),
283                },
284                LegacyLint::CollapsesTo {
285                    chord: "ctrl+shift+s".to_string(),
286                    collapses_to: "ctrl+s".to_string(),
287                },
288                LegacyLint::Unrepresentable {
289                    chord: "super+s".to_string(),
290                },
291            ]
292        );
293    }
294
295    #[test]
296    fn legacy_lints_super_dominates_over_shift_collapse() {
297        // cmd+shift+s holds super, so it is Unrepresentable, not a CollapsesTo —
298        // the precedence must survive the lint mapping.
299        let map = lint_map(&[KeyInput::new(
300            Key::Char('s'),
301            Modifiers::SUPER | Modifiers::SHIFT,
302        )]);
303        assert_eq!(
304            legacy_lints(&map),
305            vec![LegacyLint::Unrepresentable {
306                chord: "shift+super+s".to_string(),
307            }]
308        );
309    }
310
311    #[test]
312    fn legacy_lints_omit_representable_and_count_matches() {
313        // Three representable + two not: only the two are reported (no-drop,
314        // no-spurious count, independent of legacy_form's per-chord rule).
315        let map = lint_map(&[
316            KeyInput::new(Key::Char('a'), Modifiers::CTRL), // representable
317            KeyInput::new(Key::Up, Modifiers::NONE),        // representable
318            KeyInput::new(Key::F(1), Modifiers::NONE),      // representable
319            KeyInput::new(Key::Char('1'), Modifiers::SUPER), // unrepresentable
320            ctrl('m'),                                      // collapses to enter
321        ]);
322        assert_eq!(legacy_lints(&map).len(), 2);
323    }
324
325    #[test]
326    fn legacy_lints_collapses_to_target_is_canonical_and_representable() {
327        // Every reported collapse target must parse back and itself be
328        // Representable — guards the string rendering of renamed targets.
329        let map = lint_map(&[ctrl('i'), ctrl('m'), ctrl('['), ctrl_shift('s')]);
330        for lint in legacy_lints(&map) {
331            if let LegacyLint::CollapsesTo { collapses_to, .. } = lint {
332                let parsed: KeyInput = collapses_to.parse().expect("target parses");
333                assert_eq!(parsed.legacy_form(), LegacyForm::Representable);
334            }
335        }
336    }
337
338    #[test]
339    fn collapse_target_is_itself_a_stable_legacy_form() {
340        // The law: whatever a chord collapses to must itself be Representable
341        // (the collapsed form is what the terminal actually delivers).
342        for chord in [
343            ctrl_shift('s'),
344            ctrl('i'),
345            ctrl('m'),
346            ctrl('['),
347            ctrl_shift('i'),
348        ] {
349            if let LegacyForm::CollapsesTo(target) = chord.legacy_form() {
350                assert_eq!(
351                    target.legacy_form(),
352                    LegacyForm::Representable,
353                    "{chord:?} collapsed to a non-stable form {target:?}"
354                );
355            } else {
356                panic!("{chord:?} expected to collapse");
357            }
358        }
359    }
360}