diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs new file mode 100644 index 000000000..d99a8805c --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -0,0 +1,612 @@ +namespace Spectre.Console.Ansi.Tests; + +public sealed class AnsiParserTests +{ + [Fact(DisplayName = "esc: ESC ( B")] + public void Esc_Sequence_1() + { + // Given, When + var result = AnsiParserFixture.Parse("\e(B"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(esc => + { + esc.Intermediates.ShouldBe(['(']); + esc.Final.ShouldBe('B'); + }); + } + + [Fact(DisplayName = "csi: ESC [ H")] + public void Csi_Sequence_1() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[H"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Intermediates.Count.ShouldBe(0); + csi.ParamsRaw.ShouldBe(""); + csi.Params.Count.ShouldBe(0); + csi.Final.ShouldBe('H'); + }); + } + + [Fact(DisplayName = "csi: ESC [ 1 ; 4 H")] + public void Csi_Sequence_2() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[1;4H"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Intermediates.Count.ShouldBe(0); + + csi.ParamsRaw.ShouldBe("1;4"); + csi.Params.Count.ShouldBe(2); + csi.Params[0].ShouldBe(1); + csi.Params[1].ShouldBe(4); + + csi.Final.ShouldBe('H'); + }); + } + + [Fact(DisplayName = "csi: ESC [ 38 : 2 m")] + public void Csi_Sequence_3() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[38:2m"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Intermediates.Count.ShouldBe(0); + + csi.ParamsRaw.ShouldBe("38:2"); + csi.Params.Count.ShouldBe(2); + csi.Params[0].ShouldBe(38); + csi.Params[1].ShouldBe(2); + + csi.Final.ShouldBe('m'); + }); + } + + [Fact(DisplayName = "csi: ESC [ 38 ; 2 m")] + public void Csi_Sequence_4() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[38;2m"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Intermediates.Count.ShouldBe(0); + + csi.ParamsRaw.ShouldBe("38;2"); + csi.Params.Count.ShouldBe(2); + csi.Params[0].ShouldBe(38); + csi.Params[1].ShouldBe(2); + + csi.Final.ShouldBe('m'); + }); + } + + [Fact(DisplayName = "csi: ESC [ ? 2026 $ p")] + public void Csi_Sequence_5() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[?2026$p"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Intermediates.Count.ShouldBe(2); + csi.Intermediates[0].ShouldBe('?'); + csi.Intermediates[1].ShouldBe('$'); + + csi.ParamsRaw.ShouldBe("2026"); + csi.Params.Count.ShouldBe(1); + csi.Params[0].ShouldBe(2026); + + csi.Final.ShouldBe('p'); + }); + } + + [Fact(DisplayName = "csi: ESC [ ; H")] + public void Csi_Empty_Params() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[;H"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.ParamsRaw.ShouldBe(";"); + csi.Params.Count.ShouldBe(2); + csi.Params[0].ShouldBe(0); + csi.Params[1].ShouldBe(0); + csi.Final.ShouldBe('H'); + }); + } + + [Fact(DisplayName = "csi: ESC [ ;; H")] + public void Csi_Multiple_Empty_Params() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[;;H"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.ParamsRaw.ShouldBe(";;"); + csi.Params.Count.ShouldBe(3); + csi.Params[0].ShouldBe(0); + csi.Params[1].ShouldBe(0); + csi.Params[2].ShouldBe(0); + csi.Final.ShouldBe('H'); + }); + } + + [Fact(DisplayName = "csi: parameter overflow saturates")] + public void Csi_Parameter_Overflow_Saturates() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[99999999999m"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And(csi => + { + csi.Params.Count.ShouldBe(1); + csi.Params[0].ShouldBe(65535); + csi.Final.ShouldBe('m'); + }); + } + + [Fact(DisplayName = "token: identical CSI tokens are equal")] + public void Token_Csi_Value_Equality() + { + // Given, When + var a = AnsiParserFixture.Parse("\e[1;2m")[0]; + var b = AnsiParserFixture.Parse("\e[1;2m")[0]; + + // Then + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact(DisplayName = "token: differing CSI tokens are not equal")] + public void Token_Csi_Value_Inequality() + { + // Given, When + var a = AnsiParserFixture.Parse("\e[1;2m")[0]; + var b = AnsiParserFixture.Parse("\e[1;3m")[0]; + + // Then + a.ShouldNotBe(b); + } + + [Fact(DisplayName = "token: identical ESC tokens are equal")] + public void Token_Esc_Value_Equality() + { + // Given, When + var a = AnsiParserFixture.Parse("\e(B")[0]; + var b = AnsiParserFixture.Parse("\e(B")[0]; + + // Then + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact(DisplayName = "flush: emits a trailing lone high surrogate")] + public void Flush_Trailing_High_Surrogate() + { + // Given + var result = new List(); + var parser = new AnsiParser(result.Add); + parser.Next((char)0xD83D); // lone high surrogate, buffered awaiting its pair + + // When + result.Count.ShouldBe(0); + parser.Flush(); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0xFFFD)); + } + + [Fact(DisplayName = "reset: recovers mid-sequence")] + public void Reset_Mid_Sequence() + { + // Given + var result = new List(); + var parser = new AnsiParser(result.Add); + parser.Next("\e[12"); // an unterminated CSI + result.Count.ShouldBe(0); + + // When + parser.Reset(); + parser.Next("A"); // prints from Ground + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe('A')); + } + + [Fact(DisplayName = "reset: discards a pending high surrogate")] + public void Reset_Discards_Pending_Surrogate() + { + // Given + var result = new List(); + var parser = new AnsiParser(result.Add); + parser.Next((char)0xD83D); // pending high surrogate + + // When + parser.Reset(); + parser.Flush(); + + // Then + result.ShouldBeEmpty(); + } + + [Fact(DisplayName = "osc 8: Hyperlink")] + public void Osc_Sequence_1() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;;https://example.com\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with ID")] + public void Osc_Sequence_2() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=123;https://example.com\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBe("123"); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty ID")] + public void Osc_Sequence_3() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=;https://example.com\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty key")] + public void Osc_Sequence_4() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id;https://example.com\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty key but id set")] + public void Osc_Sequence_5() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;=value:id=foo;https://example.com\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBe("foo"); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty url")] + public void Osc_Sequence_6() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=foo;\e\\"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType(); + } + + [Fact(DisplayName = "osc 8: Hyperlink end")] + public void Osc_Sequence_7() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;;\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType(); + } + + [Fact(DisplayName = "osc 8: Hyperlink start and end")] + public void Osc_Sequence_8() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;;https://example.com\e\\TEXT\e]8;;\e\\"); + + // Then + result.Count.ShouldBe(8); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType(); + result[1].ShouldBeOfType(); + result[2].ShouldBeOfType(); + result[3].ShouldBeOfType(); + result[4].ShouldBeOfType(); + result[5].ShouldBeOfType(); + result[6].ShouldBeOfType() + .And().Command.ShouldBeOfType(); + result[7].ShouldBeOfType(); + } + + [Fact(DisplayName = "osc 123: Unknown")] + public void Osc_Sequence_9() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]123;;lol\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Data.ShouldBe("123;;lol"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with semicolon in URI")] + public void Osc_Hyperlink_Semicolon_In_Uri() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;;http://example.com/a;b\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Uri.ShouldBe("http://example.com/a;b"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with colon-separated params")] + public void Osc_Hyperlink_Colon_Separated_Params() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=abc:foo=bar;http://x\e\\"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBe("abc"); + osc.Uri.ShouldBe("http://x"); + }); + } + + [Fact(DisplayName = "osc 0: terminated by BEL")] + public void Osc_Terminated_By_BEL() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]0;title\a"); // OSC 0 window title, BEL terminator + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => osc.Data.ShouldBe("0;title")); + } + + [Fact(DisplayName = "osc 8: Hyperlink terminated by BEL")] + public void Osc_Hyperlink_Terminated_By_BEL() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;;https://example.com\a"); + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Uri.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc: BEL terminator returns to ground")] + public void Osc_BEL_Returns_To_Ground() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]0;t\aX"); // OSC ... BEL, then a printable + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType(); + result[1].ShouldBeOfType().And(p => p.Codepoint.ShouldBe('X')); + } + + [Fact(DisplayName = "osc: buffers are reused across sequences without leaking")] + public void Osc_Reuses_Buffers_Without_Leaking() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]123;longlonglong\a\e]9;x\a"); + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => osc.Data.ShouldBe("123;longlonglong")); + result[1].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => osc.Data.ShouldBe("9;x")); + } + + [Fact(DisplayName = "osc: CAN aborts without dispatching")] + public void Osc_Can_Aborts() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]0;title\u0018"); // CAN mid-OSC + + // Then + result.Exists(t => t is AnsiToken.Osc).ShouldBeFalse(); + result.Exists(t => t is AnsiToken.Execute e && e.Function == '\u0018').ShouldBeTrue(); + } + + [Fact(DisplayName = "print: accented latin")] + public void Print_Accented_Latin() + { + // Given, When + var result = AnsiParserFixture.Parse("aä"); // "aä" + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe('a')); + result[1].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x00E4)); + } + + [Fact(DisplayName = "print: box-drawing characters")] + public void Print_Box_Drawing() + { + // Given, When + var result = AnsiParserFixture.Parse("─│┌"); // "─│┌" + + // Then + result.Count.ShouldBe(3); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x2500)); + result[1].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x2502)); + result[2].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x250C)); + } + + [Fact(DisplayName = "print: CJK character")] + public void Print_Cjk() + { + // Given, When + var result = AnsiParserFixture.Parse("日"); // "日" + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x65E5)); + } + + [Fact(DisplayName = "print: astral codepoint combines surrogate pair")] + public void Print_Astral() + { + // Given, When + var result = AnsiParserFixture.Parse("\U0001F600"); // "😀" + + // Then + result.Count.ShouldBe(1); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x1F600)); + } + + [Fact(DisplayName = "print: astral codepoint between ascii")] + public void Print_Astral_Between_Ascii() + { + // Given, When + var result = AnsiParserFixture.Parse("a\U0001F600b"); + + // Then + result.Count.ShouldBe(3); + result[0].ShouldBeOfType().And(p => p.Codepoint.ShouldBe('a')); + result[1].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x1F600)); + result[2].ShouldBeOfType().And(p => p.Codepoint.ShouldBe('b')); + } + + [Fact(DisplayName = "print: non-ascii resumes after CSI")] + public void Print_Resumes_After_Csi() + { + // Given, When + var result = AnsiParserFixture.Parse("\e[0m─"); // SGR reset, then "─" + + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType().And(csi => csi.Final.ShouldBe('m')); + result[1].ShouldBeOfType().And(p => p.Codepoint.ShouldBe(0x2500)); + } + + [Fact(DisplayName = "print: ToUtf16 encodes a BMP codepoint as one char")] + public void Print_ToUtf16_Bmp() + { + // Given, When + var result = AnsiParserFixture.Parse("─"); // "─" + + // Then + var print = result[0].ShouldBeOfType(); + print.ToUtf16().ShouldBe("─"); + print.ToUtf16().Length.ShouldBe(1); + } + + [Fact(DisplayName = "print: ToUtf16 encodes an astral codepoint as a surrogate pair")] + public void Print_ToUtf16_Astral() + { + // Given, When + var result = AnsiParserFixture.Parse("\U0001F600"); // "😀" + + // Then + var print = result[0].ShouldBeOfType(); + print.ToUtf16().ShouldBe("\U0001F600"); + print.ToUtf16().Length.ShouldBe(2); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs b/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs new file mode 100644 index 000000000..f849c6a41 --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs @@ -0,0 +1,14 @@ +namespace Spectre.Console.Ansi.Tests; + +public sealed class AnsiParserFixture +{ + public static List Parse(string text) + { + var result = new List(); + var parser = new AnsiParser(token => result.Add(token)); + + parser.Next(text); + + return result; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs new file mode 100644 index 000000000..0a7371417 --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +namespace Spectre.Console.Ansi.Tests; + +/// +/// Provides extensions for testing using the Shouldly-style fluent assertions. +/// +public static class ShouldlyExtensions +{ + /// + /// Useful for fluent testing patterns where additional assertions or operations + /// are chained together in a readable manner. + /// + /// The type of the object. + /// The object to operate on. + /// The original object, to allow further chaining. + [DebuggerStepThrough] + public static T And(this T item) + { + return item; + } + + /// + /// Performs the specified action on the given object and then returns the object. + /// Useful for fluent testing patterns where additional assertions or operations + /// are chained together in a readable manner. + /// + /// The type of the object. + /// The object to operate on. + /// An action to perform on the object. + /// The original object, to allow further chaining. + /// Thrown if is null. + [DebuggerStepThrough] + public static T And(this T item, Action action) + { + ArgumentNullException.ThrowIfNull(action); + + action(item); + return item; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/AnsiDetector.cs b/src/Spectre.Console.Ansi/AnsiDetector.cs index a31831944..97125d6cc 100644 --- a/src/Spectre.Console.Ansi/AnsiDetector.cs +++ b/src/Spectre.Console.Ansi/AnsiDetector.cs @@ -22,7 +22,8 @@ internal static class AnsiDetector new("konsole"), // Konsole new("bvterm"), // Bitvise SSH Client new("^st-256color"), // Suckless Simple Terminal, st - new("alacritty") // Alacritty + new("alacritty"), // Alacritty + new("ghostty"), // Ghostty ]; public static (bool Ansi, bool Legacy) Detect(TextWriter buffer, AnsiSupport ansi) diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiParser.cs b/src/Spectre.Console.Ansi/Parsing/AnsiParser.cs new file mode 100644 index 000000000..c90297ca4 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiParser.cs @@ -0,0 +1,241 @@ +namespace Spectre.Console.Ansi; + +/// +/// An ANSI/VT input parser based on the VT500-series. +/// +/// +/// Instances are stateful and not thread-safe: a single parser must not be used from +/// multiple threads, and the callback must not re-enter or +/// on the same instance. +/// +public sealed class AnsiParser +{ + private const int ReplacementCodepoint = 0xFFFD; + private const int MaxParameterValue = 65535; + + private readonly Action _callback; + private readonly List _intermediates = []; + private readonly List _parameters = [0]; + private readonly StringBuilder _parametersRaw = new(); + private readonly OscParser _oscParser; + private bool _hasParameter; + private char _highSurrogate; + private AnsiParserState _currentState; + + /// + /// Initializes a new instance of the class. + /// + /// The callback to be used for parsed tokens. + public AnsiParser(Action callback) + { + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + _currentState = AnsiParserState.Ground; + _oscParser = new OscParser(); + } + + /// + /// Processes the specified text. + /// + /// The text to process. + public void Next(string text) + { + foreach (var character in text) + { + Next(character); + } + } + + /// + /// Processes the specified code. + /// + /// The code to process. + public void Next(char code) + { + // A stashed high surrogate must be immediately followed by a low surrogate to form a + // scalar value. If the next character is anything else, the high surrogate was unpaired + if (_highSurrogate != '\0' && !char.IsLowSurrogate(code)) + { + _highSurrogate = '\0'; + _callback(new AnsiToken.Print(ReplacementCodepoint)); + } + + var (nextState, action) = AnsiTransitionTable.Shared.GetTransition(_currentState, code); + + // Perform the exit action of the current state + if (_currentState != nextState) + { + switch (_currentState) + { + case AnsiParserState.OscString: + // CAN and SUB abort the string; only a normal terminator (ST/BEL) + // dispatches the accumulated command. + if (!IsAbort(code)) + { + var command = _oscParser.End(); + if (command != null) + { + _callback(new AnsiToken.Osc(Command: command)); + } + } + + break; + case AnsiParserState.DcsPassthrough: + _callback(new AnsiToken.DcsUnhook()); + break; + } + } + + // Perform the transition action + switch (action) + { + case AnsiTransitionAction.None: + case AnsiTransitionAction.Ignore: + // Do nothing + break; + case AnsiTransitionAction.Print: + EmitPrint(code); + break; + case AnsiTransitionAction.Execute: + _callback(new AnsiToken.Execute(Function: code)); + break; + case AnsiTransitionAction.Collect: + _intermediates.Add(code); + break; + case AnsiTransitionAction.Param: + _parametersRaw.Append(code); + + // A separator marks a parameter position, so set this for separators too. + // An all-empty section like "ESC [ ; H" then reports its default positions + // instead of collapsing to no params + _hasParameter = true; + + if (code is ';' or ':') + { + _parameters.Add(0); + } + else + { + Debug.Assert(char.IsDigit(code), "Expected digit"); + + var accumulator = (_parameters[^1] * 10L) + (code - 48); + _parameters[^1] = accumulator > MaxParameterValue ? MaxParameterValue : (int)accumulator; + } + + break; + case AnsiTransitionAction.EscDispatch: + _callback(new AnsiToken.Esc( + Intermediates: [.. _intermediates], + Final: code)); + break; + case AnsiTransitionAction.CsiDispatch: + _callback(new AnsiToken.Csi( + Intermediates: [.. _intermediates], + Params: _hasParameter ? [.. _parameters] : [], + Final: code, + ParamsRaw: _parametersRaw.ToString())); + break; + case AnsiTransitionAction.OscPut: + _oscParser.Next(code); + break; + case AnsiTransitionAction.DscPut: + _callback(new AnsiToken.DcsPut(Code: code)); + break; + } + + // Perform the entry action of the next state + if (_currentState != nextState) + { + switch (nextState) + { + case AnsiParserState.Escape: + case AnsiParserState.DcsEntry: + case AnsiParserState.CsiEntry: + Clear(); + break; + case AnsiParserState.OscString: + _oscParser.Reset(); + break; + case AnsiParserState.DcsPassthrough: + _callback(new AnsiToken.DcsHook( + Intermediates: [.. _intermediates], + Params: _hasParameter ? [.. _parameters] : [], + Final: code, + ParamsRaw: _parametersRaw.ToString())); + break; + } + } + + _currentState = nextState; + } + + /// + /// Emits any buffered output. Call this once at the end of the input stream so a trailing + /// unpaired high surrogate is emitted (as the Unicode replacement character) instead of + /// being silently held back while it waits for a low surrogate that never arrives. + /// + public void Flush() + { + if (_highSurrogate != '\0') + { + _highSurrogate = '\0'; + _callback(new AnsiToken.Print(ReplacementCodepoint)); + } + } + + /// + /// Resets the parser to its initial ground state, discarding any partially parsed sequence + /// and buffered state. Use this to recover from malformed input or to reuse the instance for + /// an unrelated stream. No tokens are emitted. + /// + public void Reset() + { + _currentState = AnsiParserState.Ground; + _highSurrogate = '\0'; + _oscParser.Reset(); + Clear(); + } + + private void Clear() + { + _hasParameter = false; + _parametersRaw.Clear(); + _parameters.Clear(); + _parameters.Add(0); + _intermediates.Clear(); + } + + private void EmitPrint(char code) + { + if (char.IsHighSurrogate(code)) + { + // Wait for the trailing low surrogate before emitting a scalar value + _highSurrogate = code; + return; + } + + if (char.IsLowSurrogate(code)) + { + if (_highSurrogate != '\0') + { + _callback(new AnsiToken.Print(char.ConvertToUtf32(_highSurrogate, code))); + _highSurrogate = '\0'; + } + else + { + // Low surrogate without a preceding high surrogate + _callback(new AnsiToken.Print(ReplacementCodepoint)); + } + + return; + } + + _callback(new AnsiToken.Print(code)); + } + + private static bool IsAbort(char code) + { + // CAN (0x18) and SUB (0x1A) abort any in-progress + // sequence per the VT500 state machine + return code is '\u0018' or '\u001A'; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiParserState.cs b/src/Spectre.Console.Ansi/Parsing/AnsiParserState.cs new file mode 100644 index 000000000..c82467e37 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiParserState.cs @@ -0,0 +1,19 @@ +namespace Spectre.Console.Ansi; + +internal enum AnsiParserState +{ + Ground = 0, + Escape, + EscapeIntermediate, + CsiEntry, + CsiIntermediate, + CsiParam, + CsiIgnore, + DcsEntry, + DcsParam, + DcsIntermediate, + DcsPassthrough, + DcsIgnore, + OscString, + SosPmApcString, +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs new file mode 100644 index 000000000..974b66c15 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs @@ -0,0 +1,181 @@ +namespace Spectre.Console.Ansi; + +/// +/// Represents a parsed ANSI/VT token. +/// +public abstract record AnsiToken +{ + /// + /// Prints a Unicode scalar value (codepoint) to the screen. + /// + /// + /// The Unicode codepoint to print. Astral codepoints, encoded as UTF-16 + /// surrogate pairs in the input, are combined into a single scalar value. + /// + public record Print(int Codepoint) : AnsiToken + { + /// + /// Converts the to its UTF-16 representation, suitable + /// for writing to a or a . + /// + /// + /// A string of one or two values; an astral codepoint is + /// returned as a UTF-16 surrogate pair. The parser only ever emits valid Unicode + /// scalar values, so this never throws. + /// + public string ToUtf16() + { + return char.ConvertFromUtf32(Codepoint); + } + } + + /// + /// Executes the specified function. + /// + /// The C0/C1 function to execute. + public record Execute(char Function) : AnsiToken; + + /// + /// Execute an ESC command. + /// + /// Intermediate bytes. + /// The final byte identifying the ESC command. + public record Esc(IReadOnlyList Intermediates, char Final) : AnsiToken + { + /// + public virtual bool Equals(Esc? other) + { + return other is not null + && base.Equals(other) + && Intermediates.SequenceEqual(other.Intermediates) + && Final == other.Final; + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = base.GetHashCode(); + hash = (hash * 397) ^ Final.GetHashCode(); + foreach (var intermediate in Intermediates) + { + hash = (hash * 397) ^ intermediate.GetHashCode(); + } + + return hash; + } + } + } + + /// + /// Executes a CSI command. + /// + /// Intermediate bytes. + /// The parameters. + /// The final byte identifying the CSI command. + /// The raw parameters. + public record Csi( + IReadOnlyList Intermediates, + IReadOnlyList Params, + char Final, + string ParamsRaw) : AnsiToken + { + /// + public virtual bool Equals(Csi? other) + { + return other is not null + && base.Equals(other) + && Intermediates.SequenceEqual(other.Intermediates) + && Params.SequenceEqual(other.Params) + && Final == other.Final + && ParamsRaw == other.ParamsRaw; + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = base.GetHashCode(); + hash = (hash * 397) ^ Final.GetHashCode(); + hash = (hash * 397) ^ ParamsRaw.GetHashCode(); + foreach (var intermediate in Intermediates) + { + hash = (hash * 397) ^ intermediate.GetHashCode(); + } + + foreach (var parameter in Params) + { + hash = (hash * 397) ^ parameter; + } + + return hash; + } + } + } + + /// + /// Executes an OSC command. + /// + /// The command to execute. + public record Osc(OscCommand Command) : AnsiToken; + + /// + /// Sets up a DCS handler. + /// + /// Intermediate bytes. + /// The parameters. + /// The final byte identifying the DCS command. + /// The raw parameters. + public record DcsHook( + IReadOnlyList Intermediates, + IReadOnlyList Params, + char Final, + string ParamsRaw) : AnsiToken + { + /// + public virtual bool Equals(DcsHook? other) + { + return other is not null + && base.Equals(other) + && Intermediates.SequenceEqual(other.Intermediates) + && Params.SequenceEqual(other.Params) + && Final == other.Final + && ParamsRaw == other.ParamsRaw; + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = base.GetHashCode(); + hash = (hash * 397) ^ Final.GetHashCode(); + hash = (hash * 397) ^ ParamsRaw.GetHashCode(); + foreach (var intermediate in Intermediates) + { + hash = (hash * 397) ^ intermediate.GetHashCode(); + } + + foreach (var parameter in Params) + { + hash = (hash * 397) ^ parameter; + } + + return hash; + } + } + } + + /// + /// Puts a byte into the selected handler. + /// + /// The byte to put. + public record DcsPut(char Code) : AnsiToken; + + /// + /// Unselects the current specified DCS handler. + /// + public record DcsUnhook : AnsiToken; +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiTransition.cs b/src/Spectre.Console.Ansi/Parsing/AnsiTransition.cs new file mode 100644 index 000000000..c4f9c099f --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiTransition.cs @@ -0,0 +1,5 @@ +namespace Spectre.Console.Ansi; + +internal readonly record struct AnsiTransition( + AnsiParserState State, + AnsiTransitionAction Action); \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiTransitionAction.cs b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionAction.cs new file mode 100644 index 000000000..72d4b209e --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionAction.cs @@ -0,0 +1,15 @@ +namespace Spectre.Console.Ansi; + +internal enum AnsiTransitionAction +{ + None = 0, + Ignore, + Print, + Execute, + Collect, + Param, + EscDispatch, + CsiDispatch, + OscPut, + DscPut, +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs new file mode 100644 index 000000000..6fa2dc43a --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs @@ -0,0 +1,330 @@ +namespace Spectre.Console.Ansi; + +internal sealed class AnsiTransitionTable +{ + private readonly Dictionary> _transitions; + + public static AnsiTransitionTable Shared { get; } = new(); + + private AnsiTransitionTable() + { + _transitions = []; + + // Anywhere + foreach (var state in EnumUtils.GetValues()) + { + // -> Ground + Add(0x18, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x1A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x80..0x8f, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x91..0x97, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x99, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x9A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x9C, state, AnsiParserState.Ground, AnsiTransitionAction.None); + + // -> SosPmApcString + Add(0x98, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x9E, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x9F, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + + // -> Escape + Add(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.None); + + // -> DcsEntry + Add(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.None); + + // -> OscString + Add(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.None); + + // -> CsiEntry + Add(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.None); + } + + // Ground + { + Add(0x19, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Add(0x20..0x7F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Print); + } + + // EscapeIntermediate + { + Add(0x19, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, + AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, + AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, + AnsiTransitionAction.Execute); + Add(0x20..0x2F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, + AnsiTransitionAction.Collect); + Add(0x7F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, + AnsiTransitionAction.Ignore); + + // -> Ground + Add(0x30..0x7E, AnsiParserState.EscapeIntermediate, AnsiParserState.Ground, + AnsiTransitionAction.EscDispatch); + } + + // Escape + { + Add(0x19, AnsiParserState.Escape, AnsiParserState.Escape, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.Escape, AnsiParserState.Escape, AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.Escape, AnsiParserState.Escape, AnsiTransitionAction.Execute); + Add(0x7F, AnsiParserState.Escape, AnsiParserState.Escape, AnsiTransitionAction.Ignore); + + // -> EscapeIntermediate + Add(0x20..0x2F, AnsiParserState.Escape, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Collect); + + // -> Ground + Add(0x30..0x4F, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + Add(0x51..0x57, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + Add(0x59, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + Add(0x5A, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + Add(0x5C, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + Add(0x60..0x7E, AnsiParserState.Escape, AnsiParserState.Ground, AnsiTransitionAction.EscDispatch); + + // -> SosPmApcString + Add(0x58, AnsiParserState.Escape, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x5E, AnsiParserState.Escape, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x5F, AnsiParserState.Escape, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + + // -> DcsEntry + Add(0x50, AnsiParserState.Escape, AnsiParserState.DcsEntry, AnsiTransitionAction.None); + + // -> OscString + Add(0x5D, AnsiParserState.Escape, AnsiParserState.OscString, AnsiTransitionAction.None); + + // -> CsiEntry + Add(0x5B, AnsiParserState.Escape, AnsiParserState.CsiEntry, AnsiTransitionAction.None); + } + + // SosPmApcString + { + Add(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, + AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, + AnsiTransitionAction.Ignore); + Add(0x20..0x7F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, + AnsiTransitionAction.Ignore); + } + + // DcsEntry + { + Add(0x19, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Add(0x7F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + + // -> DcsIntermediate + Add(0x20..0x2F, AnsiParserState.DcsEntry, AnsiParserState.DcsIntermediate, AnsiTransitionAction.Collect); + + // -> DcsIgnore + Add(0x3A, AnsiParserState.DcsEntry, AnsiParserState.DcsIgnore, AnsiTransitionAction.None); + + // -> DcsParam + Add(0x30..0x39, AnsiParserState.DcsEntry, AnsiParserState.DcsParam, AnsiTransitionAction.Param); + Add(0x3B, AnsiParserState.DcsEntry, AnsiParserState.DcsParam, AnsiTransitionAction.Param); + Add(0x3C..0x3F, AnsiParserState.DcsEntry, AnsiParserState.DcsParam, AnsiTransitionAction.Collect); + + // -> DcsPassthrough + Add(0x40..0x7E, AnsiParserState.DcsEntry, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); + } + + // DcsIntermediate + { + Add(0x19, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIntermediate, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIntermediate, + AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIntermediate, + AnsiTransitionAction.Ignore); + Add(0x7F, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIntermediate, AnsiTransitionAction.Ignore); + + // -> DscIgnore + Add(0x30..0x3F, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIgnore, AnsiTransitionAction.None); + + // -> DcsPassthrough + Add(0x40..0x7E, AnsiParserState.DcsIntermediate, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); + } + + // DcsIgnore + { + Add(0x19, AnsiParserState.DcsIgnore, AnsiParserState.DcsIgnore, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.DcsIgnore, AnsiParserState.DcsIgnore, AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.DcsIgnore, AnsiParserState.DcsIgnore, AnsiTransitionAction.Ignore); + Add(0x20..0x7F, AnsiParserState.DcsIgnore, AnsiParserState.DcsIgnore, AnsiTransitionAction.Ignore); + } + + // DcsParam + { + Add(0x19, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Ignore); + Add(0x30..0x39, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Param); + Add(0x3B, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Param); + Add(0x7F, AnsiParserState.DcsParam, AnsiParserState.DcsParam, AnsiTransitionAction.Ignore); + + // -> DcsParam + Add(0x3A, AnsiParserState.DcsParam, AnsiParserState.DcsIgnore, AnsiTransitionAction.None); + Add(0x3C..0x3F, AnsiParserState.DcsParam, AnsiParserState.DcsIgnore, AnsiTransitionAction.None); + + // -> DcsIntermediate + Add(0x20..0x2F, AnsiParserState.DcsParam, AnsiParserState.DcsIntermediate, AnsiTransitionAction.Collect); + + // -> DcsPassthrough + Add(0x40..0x7E, AnsiParserState.DcsParam, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); + } + + // DcsPassthrough + { + Add(0x19, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, AnsiTransitionAction.DscPut); + Add(0x00..0x17, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, + AnsiTransitionAction.DscPut); + Add(0x1C..0x1F, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, + AnsiTransitionAction.DscPut); + Add(0x20..0x7E, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, + AnsiTransitionAction.DscPut); + Add(0x7F, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, AnsiTransitionAction.Ignore); + } + + // CsiParam + { + Add(0x19, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Execute); + Add(0x30..0x39, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Param); + Add(0x3B, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Param); + Add(0x7F, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Ignore); + + // -> CsiIgnore (0x3A diffs from spec, but needed for ':') + Add(0x3C..0x3F, AnsiParserState.CsiParam, AnsiParserState.CsiIgnore, AnsiTransitionAction.None); + Add(0x3A, AnsiParserState.CsiParam, AnsiParserState.CsiParam, AnsiTransitionAction.Param); + + // -> CsiIntermediate + Add(0x20..0x2F, AnsiParserState.CsiParam, AnsiParserState.CsiIntermediate, AnsiTransitionAction.Collect); + + // -> Ground + Add(0x40..0x7E, AnsiParserState.CsiParam, AnsiParserState.Ground, AnsiTransitionAction.CsiDispatch); + } + + // CsiIgnore + { + Add(0x19, AnsiParserState.CsiIgnore, AnsiParserState.CsiIgnore, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.CsiIgnore, AnsiParserState.CsiIgnore, AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.CsiIgnore, AnsiParserState.CsiIgnore, AnsiTransitionAction.Execute); + Add(0x20..0x3F, AnsiParserState.CsiIgnore, AnsiParserState.CsiIgnore, AnsiTransitionAction.Ignore); + Add(0x7F, AnsiParserState.CsiIgnore, AnsiParserState.CsiIgnore, AnsiTransitionAction.Ignore); + + // -> Ground + Add(0x40..0x7E, AnsiParserState.CsiIgnore, AnsiParserState.Ground, AnsiTransitionAction.None); + } + + // CsiIntermediate + { + Add(0x19, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIntermediate, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIntermediate, + AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIntermediate, + AnsiTransitionAction.Execute); + Add(0x20..0x2F, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIntermediate, + AnsiTransitionAction.Collect); + Add(0x7F, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIntermediate, AnsiTransitionAction.Ignore); + + // -> CsiIgnore + Add(0x30..0x3F, AnsiParserState.CsiIntermediate, AnsiParserState.CsiIgnore, AnsiTransitionAction.None); + + // -> Ground + Add(0x40..0x7E, AnsiParserState.CsiIntermediate, AnsiParserState.Ground, AnsiTransitionAction.CsiDispatch); + } + + // CsiEntry + { + Add(0x19, AnsiParserState.CsiEntry, AnsiParserState.CsiEntry, AnsiTransitionAction.Execute); + Add(0x00..0x17, AnsiParserState.CsiEntry, AnsiParserState.CsiEntry, AnsiTransitionAction.Execute); + Add(0x1C..0x1F, AnsiParserState.CsiEntry, AnsiParserState.CsiEntry, AnsiTransitionAction.Execute); + Add(0x7F, AnsiParserState.CsiEntry, AnsiParserState.CsiEntry, AnsiTransitionAction.Ignore); + + // -> CsiParam + Add(0x30..0x39, AnsiParserState.CsiEntry, AnsiParserState.CsiParam, AnsiTransitionAction.Param); + Add(0x3B, AnsiParserState.CsiEntry, AnsiParserState.CsiParam, AnsiTransitionAction.Param); + Add(0x3C..0x3F, AnsiParserState.CsiEntry, AnsiParserState.CsiParam, AnsiTransitionAction.Collect); + + // -> CsiIgnore + Add(0x3A, AnsiParserState.CsiEntry, AnsiParserState.CsiIgnore, AnsiTransitionAction.None); + + // -> CsiIntermediate + Add(0x20..0x2F, AnsiParserState.CsiEntry, AnsiParserState.CsiIntermediate, AnsiTransitionAction.Collect); + + // -> Ground + Add(0x40..0x7E, AnsiParserState.CsiEntry, AnsiParserState.Ground, AnsiTransitionAction.CsiDispatch); + } + + // OscString + { + Add(0x19, AnsiParserState.OscString, AnsiParserState.OscString, AnsiTransitionAction.Ignore); + Add(0x00..0x17, AnsiParserState.OscString, AnsiParserState.OscString, AnsiTransitionAction.Ignore); + Add(0x1C..0x1F, AnsiParserState.OscString, AnsiParserState.OscString, AnsiTransitionAction.Ignore); + Add(0x20..0x7F, AnsiParserState.OscString, AnsiParserState.OscString, AnsiTransitionAction.OscPut); + + // -> Ground (BEL terminates OSC; xterm extension, not in the Williams diagram) + Add(0x07, AnsiParserState.OscString, AnsiParserState.Ground, AnsiTransitionAction.None); + } + } + + public AnsiTransition GetTransition( + AnsiParserState state, int code) + { + if (_transitions.TryGetValue(code, out var lookup) && + lookup.TryGetValue(state, out var transition)) + { + return transition; + } + + // The transition table only covers the C0, C1 and GL ranges (0x00-0x9F). Because the + // input is already decoded to UTF-16, any codepoint at or above 0xA0 is a graphic + // character (GR and beyond); classify it by how the current state treats printable input + if (code >= 0xA0) + { + return new AnsiTransition(state, GetPrintableAction(state)); + } + + return new AnsiTransition(state, AnsiTransitionAction.None); + } + + // How a graphic (printable) character is handled in each state. States that collect or + // ignore control sequences never expect graphic input, so they ignore it in place + private static AnsiTransitionAction GetPrintableAction(AnsiParserState state) + { + return state switch + { + AnsiParserState.Ground => AnsiTransitionAction.Print, + AnsiParserState.OscString => AnsiTransitionAction.OscPut, + AnsiParserState.DcsPassthrough => AnsiTransitionAction.DscPut, + _ => AnsiTransitionAction.Ignore, + }; + } + + private void Add( + byte code, AnsiParserState fromState, + AnsiParserState toState, AnsiTransitionAction action) + { + if (!_transitions.TryGetValue(code, out var lookup)) + { + lookup = []; + _transitions.Add(code, lookup); + } + + lookup[fromState] = new AnsiTransition(toState, action); + } + + private void Add( + Range codes, AnsiParserState fromState, + AnsiParserState toState, AnsiTransitionAction action) + { + for (var code = codes.Start.Value; code < codes.End.Value + 1; code++) + { + Add((byte)code, fromState, toState, action); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs new file mode 100644 index 000000000..bd5d901bb --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs @@ -0,0 +1,25 @@ +namespace Spectre.Console.Ansi; + +/// +/// Represents an OSC command. +/// +public abstract record OscCommand +{ + /// + /// Represents a hyperlink start. + /// + /// The hyperlink ID. + /// The hyperlink URI. + public record HyperLinkStart(string? Id, string Uri) : OscCommand; + + /// + /// Represents a hyperlink end. + /// + public record HyperLinkEnd : OscCommand; + + /// + /// Represent an unknown OSC command. + /// + /// The OSC command data. + public record Unknown(string Data) : OscCommand; +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs new file mode 100644 index 000000000..70545c1c1 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs @@ -0,0 +1,50 @@ +namespace Spectre.Console.Ansi; + +internal static class OscHyperLinkParser +{ + public static OscCommand? Parse(ReadOnlySpan buffer) + { + string? id = null; + + // The URI is everything after the first ';'. Per the OSC 8 spec the params + // section precedes it, and the URI itself may legally contain ';' + var separator = buffer.IndexOf(';'); + if (separator < 0) + { + return new OscCommand.HyperLinkStart(null, buffer.ToString()); + } + + var parameters = buffer[..separator]; + var uri = buffer[(separator + 1)..]; + + // Params are a ':'-separated list of key=value pairs. + while (!parameters.IsEmpty) + { + var colonIndex = parameters.IndexOf(':'); + var pair = colonIndex < 0 ? parameters : parameters[..colonIndex]; + + var equalsIndex = pair.IndexOf('='); + if (equalsIndex >= 0 && pair[..equalsIndex].Trim() is "id") + { + var value = pair[(equalsIndex + 1)..].Trim(); + id = value.IsEmpty ? null : value.ToString(); + } + + if (colonIndex < 0) + { + break; + } + + parameters = parameters[(colonIndex + 1)..]; + } + + if (id == null && uri.Length == 0) + { + return new OscCommand.HyperLinkEnd(); + } + + return uri.Length != 0 + ? new OscCommand.HyperLinkStart(Id: id, Uri: uri.ToString()) + : null; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs new file mode 100644 index 000000000..6ce6d7ffe --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs @@ -0,0 +1,72 @@ +namespace Spectre.Console.Ansi; + +internal sealed class OscParser +{ + // Upper bound on an OSC string. Anything longer is silently truncated to this length, + // which caps both the OSC 8 URI and the raw data of an unknown OSC command. + private const int MaxBufferSize = 2048; + + private readonly char[] _buffer = new char[MaxBufferSize]; + private int _length; + private int _payloadStart; + private State _state = State.Start; + + private enum State + { + Invalid = 0, + Start = 1, + Osc8 = 2, + HyperLink = 3, + } + + public void Reset() + { + // Reuse the buffer; every read is bounded by _length, so stale data is never observed. + _length = 0; + _payloadStart = 0; + _state = State.Start; + } + + public void Next(char code) + { + // Accumulate the whole string so an unknown OSC command can be reported in full. + if (_length < MaxBufferSize) + { + _buffer[_length] = code; + _length++; + } + + switch (_state) + { + case State.Start: + _state = code == '8' ? State.Osc8 : State.Invalid; + break; + case State.Osc8: + if (code == ';') + { + // The OSC 8 payload (params and URI) starts just after the "8;" prefix. + _payloadStart = _length; + _state = State.HyperLink; + } + else + { + _state = State.Invalid; + } + + break; + } + } + + public OscCommand? End() + { + switch (_state) + { + case State.HyperLink when _length > _payloadStart: + return OscHyperLinkParser.Parse(_buffer.AsSpan(_payloadStart, _length - _payloadStart)); + case State.Invalid when _length > 0: + return new OscCommand.Unknown(Data: new string(_buffer, 0, _length)); + default: + return null; + } + } +}