Skip to main content

inkferro_core/input/
keypress.rs

1//! Port of [`parse-keypress.ts`](../../../ink/src/parse-keypress.ts) and the
2//! kitty-protocol decoding from `kitty-keyboard.ts`.
3//!
4//! Pure function: a single terminal key sequence (the raw bytes of one
5//! [`Segment::Key`](super::segmenter::Segment) emitted by the segmenter) is
6//! decoded into a [`Key`] mirroring ink's `Key` object fields, including the
7//! kitty-protocol fields (`super`, `hyper`, `caps_lock`, `num_lock`,
8//! `event_type`, `is_kitty_protocol`, `text`, `is_printable`).
9//!
10//! The kitty CSI-u and kitty-enhanced special-key parsers are tried **first**,
11//! exactly as upstream `parseKeypress` does; on no match the legacy
12//! enquirer-derived keypress table is consulted.
13//!
14//! # Byte vs string note
15//!
16//! Upstream operates on a UTF-16 JS string. This port takes `&[u8]`. The
17//! high-bit single-byte transform (`s[0] > 127 && s[1] === undefined`) is
18//! ported on the raw bytes; everything else decodes the bytes to a `&str`
19//! once (lossily, matching `TextDecoder`) and matches on Rust `char`s, so the
20//! length/range branches (`s.length === 1`, `s <= '\x1a'`, `'0'..='9'`) behave
21//! as the char operations they are upstream.
22
23use super::kitty::KITTY_MODIFIERS;
24
25/// Press / repeat / release, mirroring ink's `eventType`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum EventType {
28    Press,
29    Repeat,
30    Release,
31}
32
33/// A decoded key event, mirroring ink's `Key` object (including kitty fields).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Key {
36    /// Resolved key name (e.g. `"up"`, `"a"`, `"return"`, `"number"`). Empty
37    /// when the sequence is unmapped.
38    pub name: String,
39    pub ctrl: bool,
40    pub meta: bool,
41    pub shift: bool,
42    /// The original sequence (decoded to text). Mirrors `key.sequence`.
43    pub sequence: String,
44    /// The raw sequence. `None` mirrors upstream's `raw: undefined` cases.
45    pub raw: Option<String>,
46    /// Reassembled escape code for legacy fn-key sequences (`key.code`).
47    pub code: Option<String>,
48    pub super_key: bool,
49    pub hyper: bool,
50    pub caps_lock: bool,
51    pub num_lock: bool,
52    /// Only set by the kitty protocol parser.
53    pub event_type: Option<EventType>,
54    /// `true` only for kitty-protocol keypresses.
55    pub is_kitty_protocol: bool,
56    /// Associated text input (kitty `text-as-codepoints`, or the default
57    /// character for printable kitty keys).
58    pub text: Option<String>,
59    /// Whether this key represents printable text input. Only set by the kitty
60    /// protocol parser (`None` for legacy keys).
61    pub is_printable: Option<bool>,
62}
63
64impl Key {
65    /// An all-default key carrying only the given `sequence`/`raw`, matching the
66    /// legacy `ParsedKey` initial object.
67    fn legacy(sequence: String) -> Self {
68        Key {
69            name: String::new(),
70            ctrl: false,
71            meta: false,
72            shift: false,
73            raw: Some(sequence.clone()),
74            sequence,
75            code: None,
76            super_key: false,
77            hyper: false,
78            caps_lock: false,
79            num_lock: false,
80            event_type: None,
81            is_kitty_protocol: false,
82            text: None,
83            is_printable: None,
84        }
85    }
86}
87
88// --- key-name tables (1:1 with parse-keypress.ts `keyName`) ---
89
90/// Returns the legacy `keyName[code]` mapping, or `None` if unmapped.
91fn key_name(code: &str) -> Option<&'static str> {
92    Some(match code {
93        // xterm/gnome ESC O letter
94        "OP" => "f1",
95        "OQ" => "f2",
96        "OR" => "f3",
97        "OS" => "f4",
98        // vt220-style ESC [ letter
99        "[P" => "f1",
100        "[Q" => "f2",
101        "[R" => "f3",
102        "[S" => "f4",
103        // xterm/rxvt ESC [ number ~
104        "[11~" => "f1",
105        "[12~" => "f2",
106        "[13~" => "f3",
107        "[14~" => "f4",
108        // from Cygwin and used in libuv
109        "[[A" => "f1",
110        "[[B" => "f2",
111        "[[C" => "f3",
112        "[[D" => "f4",
113        "[[E" => "f5",
114        // common
115        "[15~" => "f5",
116        "[17~" => "f6",
117        "[18~" => "f7",
118        "[19~" => "f8",
119        "[20~" => "f9",
120        "[21~" => "f10",
121        "[23~" => "f11",
122        "[24~" => "f12",
123        // xterm ESC [ letter
124        "[A" => "up",
125        "[B" => "down",
126        "[C" => "right",
127        "[D" => "left",
128        "[E" => "clear",
129        "[F" => "end",
130        "[H" => "home",
131        // xterm/gnome ESC O letter
132        "OA" => "up",
133        "OB" => "down",
134        "OC" => "right",
135        "OD" => "left",
136        "OE" => "clear",
137        "OF" => "end",
138        "OH" => "home",
139        // xterm/rxvt ESC [ number ~
140        "[1~" => "home",
141        "[2~" => "insert",
142        "[3~" => "delete",
143        "[4~" => "end",
144        "[5~" => "pageup",
145        "[6~" => "pagedown",
146        // putty
147        "[[5~" => "pageup",
148        "[[6~" => "pagedown",
149        // rxvt
150        "[7~" => "home",
151        "[8~" => "end",
152        // rxvt keys with modifiers
153        "[a" => "up",
154        "[b" => "down",
155        "[c" => "right",
156        "[d" => "left",
157        "[e" => "clear",
158
159        "[2$" => "insert",
160        "[3$" => "delete",
161        "[5$" => "pageup",
162        "[6$" => "pagedown",
163        "[7$" => "home",
164        "[8$" => "end",
165
166        "Oa" => "up",
167        "Ob" => "down",
168        "Oc" => "right",
169        "Od" => "left",
170        "Oe" => "clear",
171
172        "[2^" => "insert",
173        "[3^" => "delete",
174        "[5^" => "pageup",
175        "[6^" => "pagedown",
176        "[7^" => "home",
177        "[8^" => "end",
178        // misc.
179        "[Z" => "tab",
180        _ => return None,
181    })
182}
183
184fn is_shift_key(code: &str) -> bool {
185    matches!(
186        code,
187        "[a" | "[b" | "[c" | "[d" | "[e" | "[2$" | "[3$" | "[5$" | "[6$" | "[7$" | "[8$" | "[Z"
188    )
189}
190
191fn is_ctrl_key(code: &str) -> bool {
192    matches!(
193        code,
194        "Oa" | "Ob" | "Oc" | "Od" | "Oe" | "[2^" | "[3^" | "[5^" | "[6^" | "[7^" | "[8^"
195    )
196}
197
198// --- kitty special-key tables ---
199
200fn kitty_special_letter_key(terminator: char) -> Option<&'static str> {
201    Some(match terminator {
202        'A' => "up",
203        'B' => "down",
204        'C' => "right",
205        'D' => "left",
206        'E' => "clear",
207        'F' => "end",
208        'H' => "home",
209        'P' => "f1",
210        'Q' => "f2",
211        'R' => "f3",
212        'S' => "f4",
213        _ => return None,
214    })
215}
216
217fn kitty_special_number_key(number: u32) -> Option<&'static str> {
218    Some(match number {
219        2 => "insert",
220        3 => "delete",
221        5 => "pageup",
222        6 => "pagedown",
223        7 => "home",
224        8 => "end",
225        11 => "f1",
226        12 => "f2",
227        13 => "f3",
228        14 => "f4",
229        15 => "f5",
230        17 => "f6",
231        18 => "f7",
232        19 => "f8",
233        20 => "f9",
234        21 => "f10",
235        23 => "f11",
236        24 => "f12",
237        _ => return None,
238    })
239}
240
241/// Map of special codepoints to key names in the kitty protocol.
242/// (1:1 with `kittyCodepointNames`.)
243fn kitty_codepoint_name(cp: u32) -> Option<&'static str> {
244    Some(match cp {
245        27 => "escape",
246        // 13 (return) and 32 (space) are handled before this lookup.
247        9 => "tab",
248        127 => "backspace",
249        8 => "backspace",
250        57358 => "capslock",
251        57359 => "scrolllock",
252        57360 => "numlock",
253        57361 => "printscreen",
254        57362 => "pause",
255        57363 => "menu",
256        57376 => "f13",
257        57377 => "f14",
258        57378 => "f15",
259        57379 => "f16",
260        57380 => "f17",
261        57381 => "f18",
262        57382 => "f19",
263        57383 => "f20",
264        57384 => "f21",
265        57385 => "f22",
266        57386 => "f23",
267        57387 => "f24",
268        57388 => "f25",
269        57389 => "f26",
270        57390 => "f27",
271        57391 => "f28",
272        57392 => "f29",
273        57393 => "f30",
274        57394 => "f31",
275        57395 => "f32",
276        57396 => "f33",
277        57397 => "f34",
278        57398 => "f35",
279        57399 => "kp0",
280        57400 => "kp1",
281        57401 => "kp2",
282        57402 => "kp3",
283        57403 => "kp4",
284        57404 => "kp5",
285        57405 => "kp6",
286        57406 => "kp7",
287        57407 => "kp8",
288        57408 => "kp9",
289        57409 => "kpdecimal",
290        57410 => "kpdivide",
291        57411 => "kpmultiply",
292        57412 => "kpsubtract",
293        57413 => "kpadd",
294        57414 => "kpenter",
295        57415 => "kpequal",
296        57416 => "kpseparator",
297        57417 => "kpleft",
298        57418 => "kpright",
299        57419 => "kpup",
300        57420 => "kpdown",
301        57421 => "kppageup",
302        57422 => "kppagedown",
303        57423 => "kphome",
304        57424 => "kpend",
305        57425 => "kpinsert",
306        57426 => "kpdelete",
307        57427 => "kpbegin",
308        57428 => "mediaplay",
309        57429 => "mediapause",
310        57430 => "mediaplaypause",
311        57431 => "mediareverse",
312        57432 => "mediastop",
313        57433 => "mediafastforward",
314        57434 => "mediarewind",
315        57435 => "mediatracknext",
316        57436 => "mediatrackprevious",
317        57437 => "mediarecord",
318        57438 => "lowervolume",
319        57439 => "raisevolume",
320        57440 => "mutevolume",
321        57441 => "leftshift",
322        57442 => "leftcontrol",
323        57443 => "leftalt",
324        57444 => "leftsuper",
325        57445 => "lefthyper",
326        57446 => "leftmeta",
327        57447 => "rightshift",
328        57448 => "rightcontrol",
329        57449 => "rightalt",
330        57450 => "rightsuper",
331        57451 => "righthyper",
332        57452 => "rightmeta",
333        57453 => "isoLevel3Shift",
334        57454 => "isoLevel5Shift",
335        _ => return None,
336    })
337}
338
339/// Valid Unicode codepoint range, excluding surrogates.
340fn is_valid_codepoint(cp: u32) -> bool {
341    cp <= 0x10_ffff && !(0xd8_00..=0xdf_ff).contains(&cp)
342}
343
344/// `safeFromCodePoint`: the character for a codepoint, or `'?'` when invalid.
345fn safe_from_codepoint(cp: u32) -> String {
346    match char::from_u32(cp) {
347        Some(c) if is_valid_codepoint(cp) => c.to_string(),
348        _ => "?".to_string(),
349    }
350}
351
352fn resolve_event_type(value: u32) -> EventType {
353    match value {
354        3 => EventType::Release,
355        2 => EventType::Repeat,
356        _ => EventType::Press,
357    }
358}
359
360/// Modifier flags decoded from a kitty modifier value (already `value - 1`).
361struct KittyModifiers {
362    ctrl: bool,
363    shift: bool,
364    meta: bool,
365    super_key: bool,
366    hyper: bool,
367    caps_lock: bool,
368    num_lock: bool,
369}
370
371fn parse_kitty_modifiers(modifiers: u32) -> KittyModifiers {
372    KittyModifiers {
373        ctrl: modifiers & KITTY_MODIFIERS.ctrl != 0,
374        shift: modifiers & KITTY_MODIFIERS.shift != 0,
375        meta: modifiers & (KITTY_MODIFIERS.meta | KITTY_MODIFIERS.alt) != 0,
376        super_key: modifiers & KITTY_MODIFIERS.super_key != 0,
377        hyper: modifiers & KITTY_MODIFIERS.hyper != 0,
378        caps_lock: modifiers & KITTY_MODIFIERS.caps_lock != 0,
379        num_lock: modifiers & KITTY_MODIFIERS.num_lock != 0,
380    }
381}
382
383/// Parse a kitty CSI-u sequence: `CSI codepoint ; modifiers [: eventType] [;
384/// text-as-codepoints] u`. Returns `None` if it does not match the pattern.
385///
386/// On a matched-but-rejected sequence (invalid primary codepoint), returns
387/// `Some(Err(()))` so the caller can emit a safe empty kitty keypress instead
388/// of falling through to legacy parsing (mirrors `kittyKeyRe.test(s)`).
389#[allow(clippy::result_unit_err)]
390fn parse_kitty_keypress(s: &str) -> Option<Result<Key, ()>> {
391    // Pattern: ^\x1b\[(\d+)(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$
392    let body = s.strip_prefix("\u{1b}[")?.strip_suffix('u')?;
393
394    // Split on ';': [codepoint] [; modifiers(:eventType)] [; text]
395    let mut groups = body.split(';');
396    let codepoint_str = groups.next()?;
397    let codepoint: u32 = parse_all_digits(codepoint_str)?;
398
399    let (modifiers_raw, event_type_raw) = match groups.next() {
400        Some(seg) => {
401            // seg is `modifiers` or `modifiers:eventType`
402            let mut parts = seg.split(':');
403            let m = parse_all_digits(parts.next()?)?;
404            let e = match parts.next() {
405                Some(e_str) => Some(parse_all_digits(e_str)?),
406                None => None,
407            };
408            if parts.next().is_some() {
409                return None;
410            }
411            (Some(m), e)
412        }
413        None => (None, None),
414    };
415
416    let text_field = match groups.next() {
417        Some(t) => {
418            // text-as-codepoints: colon-separated digits ([\d:]+)
419            if t.is_empty() || !t.chars().all(|c| c.is_ascii_digit() || c == ':') {
420                return None;
421            }
422            Some(t)
423        }
424        None => None,
425    };
426    if groups.next().is_some() {
427        return None;
428    }
429
430    let modifiers = modifiers_raw.map_or(0, |m| m.saturating_sub(1));
431    let event_type = event_type_raw.unwrap_or(1);
432
433    if !is_valid_codepoint(codepoint) {
434        return Some(Err(()));
435    }
436
437    // Parse the text-as-codepoints field.
438    let mut text: Option<String> = match text_field {
439        Some(field) => {
440            let mut out = String::new();
441            for cp_str in field.split(':') {
442                let cp = parse_all_digits(cp_str)?;
443                out.push_str(&safe_from_codepoint(cp));
444            }
445            Some(out)
446        }
447        None => None,
448    };
449
450    // Determine key name from codepoint.
451    let (name, is_printable) = if codepoint == 32 {
452        ("space".to_string(), true)
453    } else if codepoint == 13 {
454        ("return".to_string(), true)
455    } else if let Some(n) = kitty_codepoint_name(codepoint) {
456        (n.to_string(), false)
457    } else if (1..=26).contains(&codepoint) {
458        // Ctrl+letter comes as codepoint 1-26 ('a' is 97).
459        (
460            char::from_u32(codepoint + 96)
461                .map(String::from)
462                .unwrap_or_default(),
463            false,
464        )
465    } else {
466        (safe_from_codepoint(codepoint).to_lowercase(), true)
467    };
468
469    // Default text to the character from the codepoint when not provided.
470    if is_printable && text.is_none() {
471        text = Some(safe_from_codepoint(codepoint));
472    }
473
474    let m = parse_kitty_modifiers(modifiers);
475    Some(Ok(Key {
476        name,
477        ctrl: m.ctrl,
478        meta: m.meta,
479        shift: m.shift,
480        super_key: m.super_key,
481        hyper: m.hyper,
482        caps_lock: m.caps_lock,
483        num_lock: m.num_lock,
484        event_type: Some(resolve_event_type(event_type)),
485        sequence: s.to_string(),
486        raw: Some(s.to_string()),
487        code: None,
488        is_kitty_protocol: true,
489        is_printable: Some(is_printable),
490        text,
491    }))
492}
493
494/// Parse a kitty-enhanced special key: `CSI number ; modifiers : eventType
495/// {letter|~}`. Returns `None` if no match.
496fn parse_kitty_special_key(s: &str) -> Option<Key> {
497    // Pattern: ^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$
498    let body = s.strip_prefix("\u{1b}[")?;
499    let terminator = body.chars().last()?;
500    if !(terminator.is_ascii_alphabetic() || terminator == '~') {
501        return None;
502    }
503    let body = &body[..body.len() - terminator.len_utf8()];
504
505    let mut parts = body.split(';');
506    let number: u32 = parse_all_digits(parts.next()?)?;
507    let rest = parts.next()?;
508    if parts.next().is_some() {
509        return None;
510    }
511    let mut mod_event = rest.split(':');
512    let modifiers_raw: u32 = parse_all_digits(mod_event.next()?)?;
513    let event_type: u32 = parse_all_digits(mod_event.next()?)?;
514    if mod_event.next().is_some() {
515        return None;
516    }
517
518    let modifiers = modifiers_raw.saturating_sub(1);
519
520    let name = if terminator == '~' {
521        kitty_special_number_key(number)?
522    } else {
523        kitty_special_letter_key(terminator)?
524    };
525
526    let m = parse_kitty_modifiers(modifiers);
527    Some(Key {
528        name: name.to_string(),
529        ctrl: m.ctrl,
530        meta: m.meta,
531        shift: m.shift,
532        super_key: m.super_key,
533        hyper: m.hyper,
534        caps_lock: m.caps_lock,
535        num_lock: m.num_lock,
536        event_type: Some(resolve_event_type(event_type)),
537        sequence: s.to_string(),
538        raw: Some(s.to_string()),
539        code: None,
540        is_kitty_protocol: true,
541        is_printable: Some(false),
542        text: None,
543    })
544}
545
546/// Parse a run of ASCII digits in full (the whole string must be digits),
547/// mirroring `parseInt(x, 10)` on a `\d+` capture. Returns `None` on overflow
548/// or non-digit content.
549fn parse_all_digits(s: &str) -> Option<u32> {
550    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
551        return None;
552    }
553    s.parse::<u32>().ok()
554}
555
556/// Decode a single key sequence (the raw bytes of one segmented key) into a
557/// [`Key`]. Faithful port of `parseKeypress`.
558pub fn parse_keypress(bytes: &[u8]) -> Key {
559    // High-bit single-byte transform: a lone byte > 127 becomes ESC + (byte-128).
560    let decoded: String = if bytes.len() == 1 && bytes[0] > 127 {
561        let mut s = String::from('\u{1b}');
562        s.push_str(&String::from_utf8_lossy(&[bytes[0] - 128]));
563        s
564    } else {
565        String::from_utf8_lossy(bytes).into_owned()
566    };
567    parse_keypress_str(&decoded)
568}
569
570/// Internal: `parseKeypress` operating on an already-decoded string.
571fn parse_keypress_str(s: &str) -> Key {
572    // Try kitty keyboard protocol parsers first.
573    match parse_kitty_keypress(s) {
574        Some(Ok(key)) => return key,
575        Some(Err(())) => {
576            // Matched the kitty CSI-u pattern but was rejected: return a safe
577            // empty kitty keypress instead of falling through to legacy parsing.
578            return Key {
579                name: String::new(),
580                ctrl: false,
581                meta: false,
582                shift: false,
583                sequence: s.to_string(),
584                raw: Some(s.to_string()),
585                code: None,
586                super_key: false,
587                hyper: false,
588                caps_lock: false,
589                num_lock: false,
590                event_type: None,
591                is_kitty_protocol: true,
592                is_printable: Some(false),
593                text: None,
594            };
595        }
596        None => {}
597    }
598
599    if let Some(key) = parse_kitty_special_key(s) {
600        return key;
601    }
602
603    let mut key = Key::legacy(s.to_string());
604
605    // `key.sequence = key.sequence || s || key.name;` is a no-op here (sequence
606    // is already `s`), preserved implicitly.
607
608    let chars: Vec<char> = s.chars().collect();
609
610    if s == "\r" || s == "\u{1b}\r" {
611        // carriage return (or meta+return on macOS)
612        key.raw = None;
613        key.name = "return".to_string();
614        key.meta = chars.len() == 2;
615    } else if s == "\n" {
616        // enter, should have been called linefeed
617        key.name = "enter".to_string();
618    } else if s == "\t" {
619        key.name = "tab".to_string();
620    } else if s == "\u{8}" || s == "\u{1b}\u{8}" {
621        // backspace or ctrl+h
622        key.name = "backspace".to_string();
623        key.meta = chars.first() == Some(&'\u{1b}');
624    } else if s == "\u{7f}" || s == "\u{1b}\u{7f}" {
625        // backspace
626        key.name = "backspace".to_string();
627        key.meta = chars.first() == Some(&'\u{1b}');
628    } else if s == "\u{1b}" || s == "\u{1b}\u{1b}" {
629        // escape key
630        key.name = "escape".to_string();
631        key.meta = chars.len() == 2;
632    } else if s == " " || s == "\u{1b} " {
633        key.name = "space".to_string();
634        key.meta = chars.len() == 2;
635    } else if chars.len() == 1 && chars[0] <= '\u{1a}' {
636        // ctrl+letter
637        let c = chars[0] as u32;
638        key.name = char::from_u32(c + ('a' as u32) - 1)
639            .map(String::from)
640            .unwrap_or_default();
641        key.ctrl = true;
642    } else if chars.len() == 1 && chars[0].is_ascii_digit() {
643        // number
644        key.name = "number".to_string();
645    } else if chars.len() == 1 && chars[0].is_ascii_lowercase() {
646        // lowercase letter
647        key.name = chars[0].to_string();
648    } else if chars.len() == 1 && chars[0].is_ascii_uppercase() {
649        // shift+letter
650        key.name = chars[0].to_ascii_lowercase().to_string();
651        key.shift = true;
652    } else if let Some((name, meta, shift)) = match_meta_key_code(s) {
653        // meta+character key
654        key.name = name;
655        key.meta = meta;
656        key.shift = shift;
657    } else if let Some(parsed) = match_fn_key(&chars) {
658        if chars.first() == Some(&'\u{1b}') && chars.get(1) == Some(&'\u{1b}') {
659            key.meta = true;
660        }
661        let modifier = parsed.modifier;
662        key.ctrl = modifier & 4 != 0;
663        key.meta = key.meta || (modifier & 10 != 0);
664        key.shift = modifier & 1 != 0;
665        key.code = Some(parsed.code.clone());
666        key.name = key_name(&parsed.code).unwrap_or("").to_string();
667        key.shift = is_shift_key(&parsed.code) || key.shift;
668        key.ctrl = is_ctrl_key(&parsed.code) || key.ctrl;
669    }
670
671    key
672}
673
674/// `metaKeyCodeRe`: `^(?:\x1b)([a-zA-Z0-9])$`. Returns `(name, meta, shift)`.
675fn match_meta_key_code(s: &str) -> Option<(String, bool, bool)> {
676    let inner = s.strip_prefix('\u{1b}')?;
677    let mut it = inner.chars();
678    let c = it.next()?;
679    if it.next().is_some() {
680        return None;
681    }
682    if !c.is_ascii_alphanumeric() {
683        return None;
684    }
685    let name = c.to_ascii_lowercase().to_string();
686    let shift = c.is_ascii_uppercase();
687    Some((name, true, shift))
688}
689
690struct FnKeyMatch {
691    code: String,
692    /// Signed to reproduce ink's JS two's-complement semantics: a literal `0`
693    /// modifier param (e.g. `ESC[1;0~`) yields `-1` here, so the `& {4,10,1}`
694    /// masks below are all truthy — matching `parse-keypress.ts:531`. A `u32`
695    /// would underflow-panic on the per-stdin-chunk hot path.
696    modifier: i64,
697}
698
699/// `fnKeyRe`: `^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))`.
700///
701/// Reassembles the `code` (leading ESCs, the modifier bitflag, and any
702/// meaningless `1;` stripped) and the resolved `modifier`, matching upstream.
703fn match_fn_key(chars: &[char]) -> Option<FnKeyMatch> {
704    let mut i = 0;
705    // (?:\x1b+) — one or more ESC.
706    if chars.get(i) != Some(&'\u{1b}') {
707        return None;
708    }
709    while chars.get(i) == Some(&'\u{1b}') {
710        i += 1;
711    }
712
713    // (O|N|\[|\[\[) — prefer the longest alternation match as JS regex does.
714    let prefix: String;
715    if chars.get(i) == Some(&'[') {
716        if chars.get(i + 1) == Some(&'[') {
717            prefix = "[[".to_string();
718            i += 2;
719        } else {
720            prefix = "[".to_string();
721            i += 1;
722        }
723    } else if chars.get(i) == Some(&'O') {
724        prefix = "O".to_string();
725        i += 1;
726    } else if chars.get(i) == Some(&'N') {
727        prefix = "N".to_string();
728        i += 1;
729    } else {
730        return None;
731    }
732
733    // Branch A: (\d+)(?:;(\d+))?([~^$])
734    // Branch B: (?:1;)?(\d+)?([a-zA-Z])
735    let rest = &chars[i..];
736
737    // Try branch A first (regex alternation order).
738    if let Some(m) = match_fn_branch_a(&prefix, rest) {
739        return Some(m);
740    }
741    match_fn_branch_b(&prefix, rest)
742}
743
744/// Branch A: `(\d+)(?:;(\d+))?([~^$])`.
745fn match_fn_branch_a(prefix: &str, rest: &[char]) -> Option<FnKeyMatch> {
746    let mut j = 0;
747    // (\d+)
748    let num_start = j;
749    while rest.get(j).is_some_and(|c| c.is_ascii_digit()) {
750        j += 1;
751    }
752    if j == num_start {
753        return None;
754    }
755    let p2: String = rest[num_start..j].iter().collect();
756
757    // (?:;(\d+))?
758    let mut p3: Option<String> = None;
759    if rest.get(j) == Some(&';') {
760        let mut k = j + 1;
761        let s = k;
762        while rest.get(k).is_some_and(|c| c.is_ascii_digit()) {
763            k += 1;
764        }
765        if k > s {
766            p3 = Some(rest[s..k].iter().collect());
767            j = k;
768        }
769        // If `;` not followed by digits, the optional group doesn't match;
770        // leave j at the `;` so the terminator check below fails appropriately.
771    }
772
773    // ([~^$])
774    let term = rest.get(j)?;
775    if !matches!(term, '~' | '^' | '$') {
776        return None;
777    }
778
779    let code = format!("{prefix}{p2}{term}");
780    let modifier = i64::from(p3.as_deref().and_then(parse_all_digits).unwrap_or(1)) - 1;
781    Some(FnKeyMatch { code, modifier })
782}
783
784/// Branch B: `(?:1;)?(\d+)?([a-zA-Z])`.
785fn match_fn_branch_b(prefix: &str, rest: &[char]) -> Option<FnKeyMatch> {
786    let mut j = 0;
787    // (?:1;)? — optional literal "1;".
788    if rest.get(j) == Some(&'1') && rest.get(j + 1) == Some(&';') {
789        j += 2;
790    }
791    // (\d+)?
792    let s = j;
793    while rest.get(j).is_some_and(|c| c.is_ascii_digit()) {
794        j += 1;
795    }
796    let p5: Option<String> = if j > s {
797        Some(rest[s..j].iter().collect())
798    } else {
799        None
800    };
801    // ([a-zA-Z])
802    let letter = rest.get(j)?;
803    if !letter.is_ascii_alphabetic() {
804        return None;
805    }
806
807    // `code` joins parts[1] (prefix) + parts[6] (letter) only; parts[5] (p5) is
808    // the modifier and is intentionally excluded.
809    let code = format!("{prefix}{letter}");
810    let modifier = i64::from(p5.as_deref().and_then(parse_all_digits).unwrap_or(1)) - 1;
811    Some(FnKeyMatch { code, modifier })
812}
813
814#[cfg(test)]
815mod tests;