using System.Globalization;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
///
/// Reads what a custom key should send, in the notation people already write it in.
///
///
///
/// Two notations, both the ones a terminal user has seen before: backslash escapes as printf and
/// bind write them (\e, \r, \t, \n, \\, \xHH), and caret
/// notation as stty and every man page write control characters (^C, ^[, ^?).
/// Anything else is typed as it stands, in UTF-8.
///
///
/// A caret only means Ctrl in front of a character that has a control form — a letter, or one of
/// @[\]^_?. Elsewhere, as in ^1 or a trailing ^, it is a caret. The cost is that
/// grep ^root written here sends Ctrl-R; \^ is the way to say a literal caret before a
/// letter, and the customise screen says so.
///
///
/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or \x without
/// two hex digits — so a sequence that parses is one that sends exactly what it says.
///
///
internal static class KeySequence
{
/// Turns notation into bytes.
/// False when the notation is malformed; is then empty.
internal static bool TryParse(string notation, out byte[] bytes)
{
ArgumentNullException.ThrowIfNull(notation);
var output = new List(notation.Length);
var literal = new StringBuilder();
for (var i = 0; i < notation.Length; i++)
{
var c = notation[i];
if (c == '\\')
{
if (!TryEscape(notation, ref i, output, literal))
{
bytes = [];
return false;
}
continue;
}
if (c == '^' && i + 1 < notation.Length && TryControl(notation[i + 1], out var control))
{
FlushLiteral(output, literal);
output.Add(control);
i++;
continue;
}
literal.Append(c);
}
FlushLiteral(output, literal);
bytes = [.. output];
return true;
}
/// Reads the escape whose backslash is at , leaving it on its last character.
private static bool TryEscape(string notation, ref int i, List output, StringBuilder literal)
{
if (i + 1 >= notation.Length)
{
return false;
}
var next = notation[++i];
if (next is 'x' or 'X')
{
// Exactly two digits, always. One digit followed by text would be a guess at where the number
// stops.
if (notation.Length - i - 1 < 2
|| !byte.TryParse(
notation.AsSpan(i + 1, 2),
NumberStyles.AllowHexSpecifier,
CultureInfo.InvariantCulture,
out var hex))
{
return false;
}
FlushLiteral(output, literal);
output.Add(hex);
i += 2;
return true;
}
byte? escaped = next switch
{
'e' or 'E' => 0x1B,
'r' => 0x0D,
'n' => 0x0A,
't' => 0x09,
'b' => 0x08,
'a' => 0x07,
'0' => 0x00,
_ => null,
};
if (escaped is { } value)
{
FlushLiteral(output, literal);
output.Add(value);
}
else
{
// \\, \^ and any other escaped character stand for themselves.
literal.Append(next);
}
return true;
}
/// Moves the plain text gathered so far into the output, as UTF-8.
private static void FlushLiteral(List output, StringBuilder literal)
{
if (literal.Length > 0)
{
output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
literal.Clear();
}
}
/// The control character caret notation names, where there is one.
///
/// ^? is DEL rather than a masked byte — the one exception every table of caret notation makes,
/// and the one people reach for as "backspace".
///
internal static bool TryControl(char c, out byte control)
{
if (c == '?')
{
control = 0x7F;
return true;
}
var upper = char.ToUpperInvariant(c);
if (upper is >= '@' and <= '_')
{
control = (byte)(upper & 0x1F);
return true;
}
control = 0;
return false;
}
}