File: System\IO\KeyParser.cs
Web Access
Project: src\src\libraries\System.Console\src\System.Console.csproj (System.Console)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.Diagnostics;
 
namespace System.IO;
 
internal static class KeyParser
{
    private const char Escape = '\e';
    private const char Delete = '\u007F';
    private const char VtSequenceEndTag = '~';
    private const char ModifierSeparator = ';';
    private const int MinimalSequenceLength = 3;
    private const int SequencePrefixLength = 2; // ^[[ ("^[" stands for Escape)
 
    internal static ConsoleKeyInfo Parse(char[] buffer, TerminalFormatStrings terminalFormatStrings, byte posixDisableValue, byte veraseCharacter, ref int startIndex, int endIndex)
    {
        int length = endIndex - startIndex;
        Debug.Assert(length > 0);
 
        // VERASE overrides anything from Terminfo. Both settings can be different for Linux and macOS.
        if (buffer[startIndex] != posixDisableValue && buffer[startIndex] == veraseCharacter)
        {
            // the original char is preserved on purpose (backward compat + consistency)
            return new ConsoleKeyInfo(buffer[startIndex++], ConsoleKey.Backspace, false, false, false);
        }
 
        // Escape Sequences start with Escape. But some terminals like PuTTY and rxvt prepend Escape to express that for given sequence Alt was pressed.
        if (length >= MinimalSequenceLength + 1 && buffer[startIndex] == Escape && buffer[startIndex + 1] == Escape)
        {
            startIndex++;
            if (TryParseTerminalInputSequence(buffer, terminalFormatStrings, out ConsoleKeyInfo parsed, ref startIndex, endIndex))
            {
                return new ConsoleKeyInfo(parsed.KeyChar, parsed.Key, (parsed.Modifiers & ConsoleModifiers.Shift) != 0, alt: true, (parsed.Modifiers & ConsoleModifiers.Control) != 0);
            }
            startIndex--;
        }
        else if (length >= MinimalSequenceLength && TryParseTerminalInputSequence(buffer, terminalFormatStrings, out ConsoleKeyInfo parsed, ref startIndex, endIndex))
        {
            return parsed;
        }
 
        if (length == 2 && buffer[startIndex] == Escape && buffer[startIndex + 1] != Escape)
        {
            startIndex++; // skip the Escape
            return ParseFromSingleChar(buffer[startIndex++], isAlt: true);
        }
 
        return ParseFromSingleChar(buffer[startIndex++], isAlt: false);
    }
 
    private static bool TryParseTerminalInputSequence(char[] buffer, TerminalFormatStrings terminalFormatStrings, out ConsoleKeyInfo parsed, ref int startIndex, int endIndex)
    {
        ReadOnlySpan<char> input = buffer.AsSpan(startIndex, endIndex - startIndex);
        parsed = default;
 
        // sequences start with either "^[[" or "^[O". "^[" stands for Escape (27).
        if (input.Length < MinimalSequenceLength || input[0] != Escape || (input[1] != '[' && input[1] != 'O'))
        {
            return false;
        }
 
        Dictionary<string, ConsoleKeyInfo>.AlternateLookup<ReadOnlySpan<char>> terminfoDb = // the most important source of truth
            terminalFormatStrings.KeyFormatToConsoleKey.GetAlternateLookup<ReadOnlySpan<char>>();
        ConsoleModifiers modifiers = ConsoleModifiers.None;
        ConsoleKey key;
 
        // Is it a three character sequence? (examples: '^[[H' (Home), '^[OP' (F1))
        if (input[1] == 'O' || char.IsAsciiLetter(input[2]) || input.Length == MinimalSequenceLength)
        {
            if (!terminfoDb.TryGetValue(buffer.AsSpan(startIndex, MinimalSequenceLength), out parsed))
            {
                // All terminals which use "^[O{letter}" escape sequences don't define conflicting mappings.
                // Example: ^[OH either means Home or simply is not used by given terminal.
                // But with "^[[{character}" sequences, there are conflicts between rxvt and SCO.
                // Example: "^[[a" is Shift+UpArrow for rxvt and Shift+F3 for SCO.
                (key, modifiers) = input[1] == 'O' || terminalFormatStrings.IsRxvtTerm
                    ? MapKeyIdOXterm(input[2], terminalFormatStrings.IsRxvtTerm)
                    : MapSCO(input[2]);
 
                if (key == default)
                {
                    return false; // it was not a known sequence
                }
 
                char keyChar = key switch
                {
                    ConsoleKey.Enter => '\r', // "^[OM" should produce new line character (was not previously mapped this way)
                    ConsoleKey.Add => '+',
                    ConsoleKey.Subtract => '-',
                    ConsoleKey.Divide => '/',
                    ConsoleKey.Multiply => '*',
                    _ => default
                };
                parsed = Create(keyChar, key, modifiers);
            }
 
            startIndex += MinimalSequenceLength;
            return true;
        }
 
        // Is it a four character sequence used by Linux Console or PuTTy configured to emulate it? (examples: '^[[[A' (F1), '^[[[B' (F2))
        if (input[1] == '[' && input[2] == '[' && char.IsBetween(input[3], 'A', 'E'))
        {
            if (!terminfoDb.TryGetValue(buffer.AsSpan(startIndex, 4), out parsed))
            {
                parsed = new ConsoleKeyInfo(default, ConsoleKey.F1 + input[3] - 'A', false, false, false);
            }
 
            startIndex += 4;
            return true;
        }
 
        // If sequence does not start with a letter, it must start with one or two digits that represent the Sequence Number
        int digitCount = !char.IsBetween(input[SequencePrefixLength], '1', '9') // not using IsAsciiDigit as 0 is invalid
            ? 0
            : char.IsDigit(input[SequencePrefixLength + 1]) ? 2 : 1;
 
        if (digitCount == 0 // it does not start with a digit, it's not a sequence
            || SequencePrefixLength + digitCount >= input.Length) // it's too short to be a complete sequence
        {
            parsed = default;
            return false;
        }
 
        if (IsSequenceEndTag(input[SequencePrefixLength + digitCount]))
        {
            // it's a VT Sequence like ^[[11~ or rxvt like ^[[11^
            int sequenceLength = SequencePrefixLength + digitCount + 1;
            if (!terminfoDb.TryGetValue(buffer.AsSpan(startIndex, sequenceLength), out parsed))
            {
                key = MapEscapeSequenceNumber(byte.Parse(input.Slice(SequencePrefixLength, digitCount)));
                if (key == default)
                {
                    return false; // it was not a known sequence
                }
 
                if (IsRxvtModifier(input[SequencePrefixLength + digitCount]))
                {
                    modifiers = MapRxvtModifiers(input[SequencePrefixLength + digitCount]);
                }
 
                parsed = Create(default, key, modifiers);
            }
 
            startIndex += sequenceLength;
            return true;
        }
 
        // If Sequence Number is not followed by the VT Seqence End Tag,
        // it can be followed only by a Modifier Separator, Modifier (2-8) and Key ID or VT Sequence End Tag.
        if (input[SequencePrefixLength + digitCount] is not ModifierSeparator
            || SequencePrefixLength + digitCount + 2 >= input.Length
            || !char.IsBetween(input[SequencePrefixLength + digitCount + 1], '2', '8')
            || (!char.IsAsciiLetterUpper(input[SequencePrefixLength + digitCount + 2]) && input[SequencePrefixLength + digitCount + 2] is not VtSequenceEndTag))
        {
            return false;
        }
 
        modifiers = MapXtermModifiers(input[SequencePrefixLength + digitCount + 1]);
 
        key = input[SequencePrefixLength + digitCount + 2] is VtSequenceEndTag
            ? MapEscapeSequenceNumber(byte.Parse(input.Slice(SequencePrefixLength, digitCount)))
            : MapKeyIdOXterm(input[SequencePrefixLength + digitCount + 2], terminalFormatStrings.IsRxvtTerm).key;
 
        if (key == default)
        {
            return false;
        }
 
        startIndex += SequencePrefixLength + digitCount + 3; // 3 stands for separator, modifier and end tag or id
        parsed = Create(default, key, modifiers);
        return true;
 
        // maps "^[O{character}" for all Terminals and "^[[{character}" for rxvt Terminals
        static (ConsoleKey key, ConsoleModifiers modifiers) MapKeyIdOXterm(char character, bool isRxvt)
            => character switch
            {
                'A' or 'x' => (ConsoleKey.UpArrow, 0), // lowercase used by rxvt
                'a' => (ConsoleKey.UpArrow, ConsoleModifiers.Shift), // rxvt
                'B' or 'r' => (ConsoleKey.DownArrow, 0), // lowercase used by rxv
                'b' => (ConsoleKey.DownArrow, ConsoleModifiers.Shift), // used by rxvt
                'C' or 'v' => (ConsoleKey.RightArrow, 0), // lowercase used by rxv
                'c' => (ConsoleKey.RightArrow, ConsoleModifiers.Shift), // used by rxvt
                'D' or 't' => (ConsoleKey.LeftArrow, 0), // lowercase used by rxv
                'd' => (ConsoleKey.LeftArrow, ConsoleModifiers.Shift), // used by rxvt
                'E' => (ConsoleKey.NoName, 0), // ^[OE maps to Begin, but we don't have such Key. To reproduce press Num5.
                'F' or 'q' => (ConsoleKey.End, 0),
                'H' => (ConsoleKey.Home, 0),
                'j' => (ConsoleKey.Multiply, 0), // used by both xterm and rxvt
                'k' => (ConsoleKey.Add, 0), // used by both xterm and rxvt
                'm' => (ConsoleKey.Subtract, 0), // used by both xterm and rxvt
                'M' => (ConsoleKey.Enter, 0), // used by xterm, rxvt (they have it Terminfo) and tmux (no record in Terminfo)
                'n' => (ConsoleKey.Delete, 0), // rxvt
                'o' => (ConsoleKey.Divide, 0), // used by both xterm and rxvt
                'P' => (ConsoleKey.F1, 0),
                'p' => (ConsoleKey.Insert, 0), // rxvt
                'Q' => (ConsoleKey.F2, 0),
                'R' => (ConsoleKey.F3, 0),
                'S' => (ConsoleKey.F4, 0),
                's' => (ConsoleKey.PageDown, 0), // rxvt
                'T' => (ConsoleKey.F5, 0), // VT 100+
                'U' => (ConsoleKey.F6, 0), // VT 100+
                'u' => (ConsoleKey.NoName, 0), // it should be Begin, but we don't have such (press Num5 in rxvt to reproduce)
                'V' => (ConsoleKey.F7, 0), // VT 100+
                'W' => (ConsoleKey.F8, 0), // VT 100+
                'w' when isRxvt => (ConsoleKey.Home, 0),
                'w' when !isRxvt => (ConsoleKey.End, 0),
                'X' => (ConsoleKey.F9, 0), // VT 100+
                'Y' => (ConsoleKey.F10, 0), // VT 100+
                'y' => (ConsoleKey.PageUp, 0), // rxvt
                'Z' => (ConsoleKey.F11, 0), // VT 100+
                '[' => (ConsoleKey.F12, 0), // VT 100+
                _ => default
            };
 
        // maps "^[[{character}" for SCO terminals, based on https://vt100.net/docs/vt510-rm/chapter6.html
        static (ConsoleKey key, ConsoleModifiers modifiers) MapSCO(char character)
            => character switch
            {
                'A' => (ConsoleKey.UpArrow, 0),
                'B' => (ConsoleKey.DownArrow, 0),
                'C' => (ConsoleKey.RightArrow, 0),
                'D' => (ConsoleKey.LeftArrow, 0),
                'F' => (ConsoleKey.End, 0),
                'G' => (ConsoleKey.PageDown, 0),
                'H' => (ConsoleKey.Home, 0),
                'I' => (ConsoleKey.PageUp, 0),
                _ when char.IsBetween(character, 'M', 'X') => (ConsoleKey.F1 + character - 'M', 0),
                _ when char.IsBetween(character, 'Y', 'Z') => (ConsoleKey.F1 + character - 'Y', ConsoleModifiers.Shift),
                _ when char.IsBetween(character, 'a', 'j') => (ConsoleKey.F3 + character - 'a', ConsoleModifiers.Shift),
                _ when char.IsBetween(character, 'k', 'v') => (ConsoleKey.F1 + character - 'k', ConsoleModifiers.Control),
                _ when char.IsBetween(character, 'w', 'z') => (ConsoleKey.F1 + character - 'w', ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '@' => (ConsoleKey.F5, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '[' => (ConsoleKey.F6, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '<' or '\\' => (ConsoleKey.F7, ConsoleModifiers.Control | ConsoleModifiers.Shift), // the Spec says <, PuTTy uses \.
                ']' => (ConsoleKey.F8, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '^' => (ConsoleKey.F9, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '_' => (ConsoleKey.F10, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '`' => (ConsoleKey.F11, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                '{' => (ConsoleKey.F12, ConsoleModifiers.Control | ConsoleModifiers.Shift),
                _ => default
            };
 
        // based on https://en.wikipedia.org/wiki/ANSI_escape_code#Fe_Escape_sequences
        static ConsoleKey MapEscapeSequenceNumber(byte number)
            => number switch
            {
                1 or 7 => ConsoleKey.Home,
                2 => ConsoleKey.Insert,
                3 => ConsoleKey.Delete,
                4 or 8 => ConsoleKey.End,
                5 => ConsoleKey.PageUp,
                6 => ConsoleKey.PageDown,
                // Limitation: 10 is mapped to F0, ConsoleKey does not define it so it's not supported.
                11 => ConsoleKey.F1,
                12 => ConsoleKey.F2,
                13 => ConsoleKey.F3,
                14 => ConsoleKey.F4,
                15 => ConsoleKey.F5,
                17 => ConsoleKey.F6,
                18 => ConsoleKey.F7,
                19 => ConsoleKey.F8,
                20 => ConsoleKey.F9,
                21 => ConsoleKey.F10,
                23 => ConsoleKey.F11,
                24 => ConsoleKey.F12,
                25 => ConsoleKey.F13,
                26 => ConsoleKey.F14,
                28 => ConsoleKey.F15,
                29 => ConsoleKey.F16,
                31 => ConsoleKey.F17,
                32 => ConsoleKey.F18,
                33 => ConsoleKey.F19,
                34 => ConsoleKey.F20,
                // 9, 16, 22, 27, 30 and 35 have no mapping
                _ => default
            };
 
        // based on https://www.xfree86.org/current/ctlseqs.html
        static ConsoleModifiers MapXtermModifiers(char modifier)
            => modifier switch
            {
                '2' => ConsoleModifiers.Shift,
                '3' => ConsoleModifiers.Alt,
                '4' => ConsoleModifiers.Shift | ConsoleModifiers.Alt,
                '5' => ConsoleModifiers.Control,
                '6' => ConsoleModifiers.Shift | ConsoleModifiers.Control,
                '7' => ConsoleModifiers.Alt | ConsoleModifiers.Control,
                '8' => ConsoleModifiers.Shift | ConsoleModifiers.Alt | ConsoleModifiers.Control,
                _ => default
            };
 
        static bool IsSequenceEndTag(char character) => character is VtSequenceEndTag || IsRxvtModifier(character);
 
        static bool IsRxvtModifier(char character) => MapRxvtModifiers(character) != default;
 
        static ConsoleModifiers MapRxvtModifiers(char modifier)
            => modifier switch
            {
                '^' => ConsoleModifiers.Control,
                '$' => ConsoleModifiers.Shift,
                '@' => ConsoleModifiers.Control | ConsoleModifiers.Shift,
                _ => default
            };
 
        static ConsoleKeyInfo Create(char keyChar, ConsoleKey key, ConsoleModifiers modifiers)
            => new(keyChar, key, (modifiers & ConsoleModifiers.Shift) != 0, (modifiers & ConsoleModifiers.Alt) != 0, (modifiers & ConsoleModifiers.Control) != 0);
    }
 
    private static ConsoleKeyInfo ParseFromSingleChar(char single, bool isAlt)
    {
        bool isShift = false, isCtrl = false;
        char keyChar = single;
 
        ConsoleKey key = single switch
        {
            '\b' => ConsoleKey.Backspace,
            '\t' => ConsoleKey.Tab,
            '\r' or '\n' => ConsoleKey.Enter,
            ' ' => ConsoleKey.Spacebar,
            Escape => ConsoleKey.Escape, // Ctrl+[ and Ctrl+3 are also mapped to 27. Limitation: Ctrl+[ and Ctrl+3 can't be mapped.
            Delete => ConsoleKey.Backspace, // Ctrl+8 and Backspace are mapped to 127 (ASCII Delete key). Limitation: Ctrl+8 can't be mapped.
            '*' => ConsoleKey.Multiply, // We can't distinguish Dx+Shift and Multiply (Numeric Keypad). Limitation: Shift+Dx can't be mapped.
            '/' => ConsoleKey.Divide, // We can't distinguish OemX and Divide (Numeric Keypad). Limitation: OemX keys can't be mapped.
            '-' => ConsoleKey.Subtract, // We can't distinguish OemMinus and Subtract (Numeric Keypad). Limitation: OemMinus can't be mapped.
            '+' => ConsoleKey.Add, // We can't distinguish OemPlus and Add (Numeric Keypad). Limitation: OemPlus can't be mapped.
            '=' => default, // '+' is not mapped to OemPlus, so `=` is not mapped to Shift+OemPlus. Limitation: Shift+OemPlus can't be mapped.
            '!' or '@' or '#' or '$' or '%' or '^' or '&' or '&' or '*' or '(' or ')' => default, // We can't make assumptions about keyboard layout neither read it. Limitation: Shift+Dx keys can't be mapped.
            ',' => ConsoleKey.OemComma, // was not previously mapped this way
            '.' => ConsoleKey.OemPeriod, // was not previously mapped this way
            _ when char.IsAsciiLetterLower(single) => ConsoleKey.A + single - 'a',
            _ when char.IsAsciiLetterUpper(single) => UppercaseCharacter(single, out isShift),
            _ when char.IsAsciiDigit(single) => ConsoleKey.D0 + single - '0', // We can't distinguish DX and Ctrl+DX as they produce same values. Limitation: Ctrl+DX can't be mapped.
            _ when char.IsBetween(single, (char)1, (char)26) => ControlAndLetterPressed(single, isAlt, out keyChar, out isCtrl),
            _ when char.IsBetween(single, (char)28, (char)31) => ControlAndDigitPressed(single, out keyChar, out isCtrl),
            '\u0000' => ControlAndDigitPressed(single, out keyChar, out isCtrl),
            _ => default
        };
 
        if (single is '\b' or '\n')
        {
            isCtrl = true; // Ctrl+Backspace is mapped to '\b' (8), Ctrl+Enter to '\n' (10)
        }
 
        if (isAlt)
        {
            isAlt = key != default; // two char sequences starting with Escape are Alt+$Key only when we can recognize the key
        }
 
        return new ConsoleKeyInfo(keyChar, key, isShift, isAlt, isCtrl);
 
        static ConsoleKey UppercaseCharacter(char single, out bool isShift)
        {
            // Previous implementation assumed that all uppercase characters were typed using Shift.
            // Limitation: Caps Lock+(a-z) is always mapped to Shift+(a-z).
            isShift = true;
            return ConsoleKey.A + single - 'A';
        }
 
        static ConsoleKey ControlAndLetterPressed(char single, bool isAlt, out char keyChar, out bool isCtrl)
        {
            // Ctrl+(a-z) characters are mapped to values from 1 to 26.
            // Ctrl+H is mapped to 8, which also maps to Ctrl+Backspace.
            // Ctrl+I is mapped to 9, which also maps to Tab.
            // Ctrl+J is mapped to 10, which also maps to Ctrl+Enter ('\n').
            // Ctrl+M is mapped to 13, which also maps to Enter ('\r').
            // Limitation: Ctrl+H, Ctrl+I, Ctrl+J and Crl+M can't be mapped. More: https://unix.stackexchange.com/questions/563469/conflict-ctrl-i-with-tab-in-normal-mode
            Debug.Assert(single != 'b' && single != '\t' && single != '\n' && single != '\r');
 
            isCtrl = true;
            // Preserve the original character the same way Windows does (#75795),
            // but only when Alt was not pressed at the same time.
            keyChar = isAlt ? default : single;
            return ConsoleKey.A + single - 1;
        }
 
        static ConsoleKey ControlAndDigitPressed(char single, out char keyChar, out bool isCtrl)
        {
            // Ctrl+(D3-D7) characters are mapped to values from 27 to 31. Escape is also mapped to 27.
            // Limitation: Ctrl+(D1, D3, D8, D9 and D0) can't be mapped.
            Debug.Assert(single == default || char.IsBetween(single, (char)28, (char)31));
 
            isCtrl = true;
            keyChar = default; // consistent with Windows
            return single switch
            {
                '\u0000' => ConsoleKey.D2, // was not previously mapped this way
                _ => ConsoleKey.D4 + single - 28
            };
        }
    }
}