From e31e5df2e9f4148b1ccb1fc99c7ad0ae2a2f312b Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 1 Feb 2026 19:15:43 +0100 Subject: [PATCH 01/27] wip: translation table --- src/Spectre.Console.Ansi/AnsiParser.cs | 161 +++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/Spectre.Console.Ansi/AnsiParser.cs diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs new file mode 100644 index 000000000..7a1b26dff --- /dev/null +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using System.Text; + +namespace Spectre.Console.Ansi; + +public sealed class AnsiParser +{ +} + +internal enum AnsiParserState +{ + Ground = 0, + Escape, + EscapeIntermediate, + CsiEntry, + CsiIntermediate, + CsiParam, + CsiIgnore, + DcsEntry, + DcsParam, + DcsIntermediate, + DcsPassthrough, + DcsIgnore, + OscString, + SosPmApcString, +} + +internal enum AnsiTransitionAction +{ + None = 0, + Ignore, + Print, + Execute, + Collect, + Param, + EscDispatch, + CsiDispatch, + Put, + OscPut, + ApcPut, +} + +internal record struct AnsiTransition(AnsiParserState state, AnsiTransitionAction action) +{ + public AnsiParserState State { get; } = state; + public AnsiTransitionAction Action { get; } = action; +} + +internal sealed class AnsiTransitionTable +{ + private readonly Dictionary> _transitions; + + private AnsiTransitionTable() + { + _transitions = []; + + // Anywhere + foreach (var state in EnumUtils.GetValues()) + { + // Anywhere -> Ground + Single(0x18, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Single(0x1A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Range(0x80..0x8f, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Range(0x91..0x97, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Single(0x99, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Single(0x9A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Single(0x9C, state, AnsiParserState.Ground, AnsiTransitionAction.None); + + // Anywhere -> SosPmApcString + Single(0x98, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Single(0x9E, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Single(0x9F, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + + // Anywhere -> Escape + Single(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.None); + + // Anywhere -> DcsEntry + Single(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.None); + + // Anywhere -> OscString + Single(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.None); + + // Anywhere -> CsiEntry + Single(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.None); + } + + // Ground + { + // Events + Single(0x19, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Range(0x00..0x17, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Range(0x1C..0x1F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); + Range(0x20..0x7F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Print); + } + + // Escape Intermediate + { + // Events + Single(0x19, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); + Range(0x00..0x17, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); + Range(0x1C..0x1F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); + Range(0x20..0x2F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Collect); + Single(0x7F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Ignore); + } + + // SosPmApcString + { + // Events + Single(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + Range(0x00..0x17, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + Range(0x1C..0x1F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + Range(0x20..0x7F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + } + + // Escape + { + // Events + Single(0x19, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Range(0x00..0x17, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Range(0x1C..0x1F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Single(0x7F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + } + + // DcsEntry + { + // Events + Single(0x19, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Range(0x00..0x17, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Range(0x1C..0x1F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + Single(0x7F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + } + } + + private void Single( + byte code, AnsiParserState fromState, + AnsiParserState toState, AnsiTransitionAction action) + { + if (!_transitions.TryGetValue(code, out var value)) + { + value = []; + _transitions.Add(code, value); + } + + if (!value.TryAdd(fromState, new AnsiTransition(toState, action))) + { + throw new InvalidOperationException("Duplicate transition detected"); + } + } + + private void Range( + Range codes, AnsiParserState fromState, + AnsiParserState toState, AnsiTransitionAction action) + { + for (var code = codes.Start.Value; code < codes.End.Value; code++) + { + Single((byte)code, fromState, toState, action); + } + } +} From d65bbd98142be6f60bc1981cacdbca5f82415937 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 2 Feb 2026 01:32:34 +0100 Subject: [PATCH 02/27] wip: initial parsing --- .../AnsiParserTests.cs | 138 +++++ .../Utilities/ShouldlyExtensions.cs | 28 + src/Spectre.Console.Ansi/AnsiParser.cs | 493 +++++++++++++++--- 3 files changed, 588 insertions(+), 71 deletions(-) create mode 100644 src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs create mode 100644 src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs new file mode 100644 index 000000000..82785057e --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -0,0 +1,138 @@ +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.Collect.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.Collect.Count.ShouldBe(0); + 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.Collect.Count.ShouldBe(0); + + 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.Collect.Count.ShouldBe(0); + + 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.Collect.Count.ShouldBe(0); + + 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.Collect.Count.ShouldBe(2); + csi.Collect[0].ShouldBe('?'); + csi.Collect[1].ShouldBe('$'); + + csi.Params.Count.ShouldBe(1); + csi.Params[0].ShouldBe(2026); + + csi.Final.ShouldBe('p'); + }); + } +} + +internal sealed class AnsiParserFixture +{ + public static List Parse(string text) + { + var result = new List(); + var parser = new AnsiParser(token => result.Add(token)); + + foreach (var character in text) + { + parser.Next(character); + } + + 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..c3fe5dac9 --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; + +namespace Spectre.Console.Ansi.Tests; + +/// +/// Provides extensions for testing using the Shouldly-style fluent assertions. +/// +public static class ShouldlyExtensions +{ + /// + /// 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/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 7a1b26dff..74614e091 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -1,12 +1,157 @@ -using System; -using System.Collections.Generic; -using System.Reflection.Emit; -using System.Text; - namespace Spectre.Console.Ansi; public sealed class AnsiParser { + private readonly Action _callback; + private readonly List _collect = []; + private readonly List _osc = []; + private readonly List _parameters = [0]; + private bool _hasParameter = false; + private AnsiParserState _currentState; + + public AnsiParser(Action callback) + { + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + _currentState = AnsiParserState.Ground; + } + + public void Next(char code) + { + var effect = AnsiTransitionTable.Shared.GetTransition(_currentState, code); + + var nextState = effect.State; + var action = effect.Action; + + try + { + switch (action) + { + case AnsiTransitionAction.None: + case AnsiTransitionAction.Ignore: + // Do nothing + break; + case AnsiTransitionAction.Print: + _callback(new AnsiToken.Print(code)); + break; + case AnsiTransitionAction.Execute: + _callback(new AnsiToken.Execute(code)); + break; + case AnsiTransitionAction.Collect: + _collect.Add(code); + break; + case AnsiTransitionAction.Param: + if (code is ';' or ':') + { + _parameters.Add(0); + } + else + { + if (!char.IsDigit(code)) + { + // TODO: Handle this + throw new InvalidOperationException(""); + } + + var accumulator = (_parameters[^1] * 10) + code - 48; + _parameters[^1] = + accumulator > (int.MaxValue / 10) - 10 + ? 0 + : accumulator; + + _hasParameter = true; + } + + break; + case AnsiTransitionAction.EscDispatch: + _callback(new AnsiToken.Esc([.. _collect], code)); + break; + case AnsiTransitionAction.CsiDispatch: + _callback(new AnsiToken.Csi( + [.. _collect], + _hasParameter ? [.. _parameters] : [], + code)); + break; + case AnsiTransitionAction.Clear: + _parameters.Clear(); + _parameters.Add(0); + _osc.Clear(); + _collect.Clear(); + break; + case AnsiTransitionAction.OscStart: + _osc.Clear(); + break; + case AnsiTransitionAction.OscPut: + if (code >= 0x20) + { + _osc.Add(code); + } + + break; + case AnsiTransitionAction.OscEnd: + // Learned about CAN/SUB from SwiftTerm, but not sure why... + if (_osc.Count > 0 && _osc[0] != 0x18 /*CAN*/ && _osc[0] != 0x1A /*SUB*/) + { + var oscCode = 0; + var content = string.Empty; + + var osc = new string(_osc.ToArray()); + var idx = osc.IndexOf(';'); + if (idx != -1) + { + // TODO: Fix parsing + oscCode = int.Parse(osc[..idx]); + content = osc.Substring(idx, osc.Length - idx); + } + else + { + // TODO: Fix parsing + oscCode = int.Parse(osc); + } + + _callback( + new AnsiToken.Osc( + (char)oscCode, + [.. content.ToArray()])); + } + + break; + case AnsiTransitionAction.DscHook: + case AnsiTransitionAction.DscPut: + case AnsiTransitionAction.DscUnhook: + // Ignore DSC for now + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + finally + { + _currentState = nextState; + } + } + + private void DispatchEsc(char c) + { + if (_collect.Count == 0) + { + } + else if (_collect.Count == 1) + { + } + } +} + +public abstract record AnsiToken +{ + public record Print(char C) : AnsiToken; + + public record Execute(char C) : AnsiToken; + + public record Esc(List Collect, char Final) : AnsiToken; + + public record Csi(List Collect, List Params, char Final) : AnsiToken; + + public record Osc(char Code, List Data) : AnsiToken; } internal enum AnsiParserState @@ -37,21 +182,29 @@ internal enum AnsiTransitionAction Param, EscDispatch, CsiDispatch, - Put, + DscPut, + Clear, + OscStart, OscPut, - ApcPut, + OscEnd, + DscHook, + DscUnhook, } -internal record struct AnsiTransition(AnsiParserState state, AnsiTransitionAction action) +internal readonly record struct AnsiTransition( + AnsiParserState State, + AnsiTransitionAction Action) { - public AnsiParserState State { get; } = state; - public AnsiTransitionAction Action { get; } = action; + public AnsiParserState State { get; } = State; + public AnsiTransitionAction Action { get; } = Action; } internal sealed class AnsiTransitionTable { private readonly Dictionary> _transitions; + public static AnsiTransitionTable Shared { get; } = new(); + private AnsiTransitionTable() { _transitions = []; @@ -59,103 +212,301 @@ private AnsiTransitionTable() // Anywhere foreach (var state in EnumUtils.GetValues()) { - // Anywhere -> Ground - Single(0x18, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Single(0x1A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Range(0x80..0x8f, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Range(0x91..0x97, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Single(0x99, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Single(0x9A, state, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Single(0x9C, state, AnsiParserState.Ground, AnsiTransitionAction.None); + // -> 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); - // Anywhere -> SosPmApcString - Single(0x98, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); - Single(0x9E, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); - Single(0x9F, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + // -> SosPmApcString + Add(0x98, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x9E, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); + Add(0x9F, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); - // Anywhere -> Escape - Single(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.None); + // -> Escape + Add(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.Clear); - // Anywhere -> DcsEntry - Single(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.None); + // -> DcsEntry + Add(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.Clear); - // Anywhere -> OscString - Single(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.None); + // -> OscString + Add(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.OscStart); - // Anywhere -> CsiEntry - Single(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.None); + // -> CsiEntry + Add(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.Clear); } // Ground { - // Events - Single(0x19, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Range(0x00..0x17, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Range(0x1C..0x1F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Execute); - Range(0x20..0x7F, AnsiParserState.Ground, AnsiParserState.Ground, AnsiTransitionAction.Print); + 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); } - // Escape Intermediate + // EscapeIntermediate { - // Events - Single(0x19, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); - Range(0x00..0x17, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); - Range(0x1C..0x1F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Execute); - Range(0x20..0x2F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Collect); - Single(0x7F, AnsiParserState.EscapeIntermediate, AnsiParserState.EscapeIntermediate, AnsiTransitionAction.Ignore); + 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); } - // SosPmApcString + // Escape { - // Events - Single(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); - Range(0x00..0x17, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); - Range(0x1C..0x1F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); - Range(0x20..0x7F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + 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.Clear); + + // -> OscString + Add(0x5D, AnsiParserState.Escape, AnsiParserState.OscString, AnsiTransitionAction.OscStart); + + // -> CsiEntry + Add(0x5B, AnsiParserState.Escape, AnsiParserState.CsiEntry, AnsiTransitionAction.Clear); } - // Escape + // SosPmApcString { - // Events - Single(0x19, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Range(0x00..0x17, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Range(0x1C..0x1F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Single(0x7F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + 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 { - // Events - Single(0x19, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Range(0x00..0x17, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Range(0x1C..0x1F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); - Single(0x7F, AnsiParserState.DcsEntry, AnsiParserState.DcsEntry, AnsiTransitionAction.Ignore); + 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.DscHook); + } + + // 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.DscHook); + } + + // 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.DscHook); + } + + // 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); + + // -> Ground + Add(0x9C, AnsiParserState.DcsPassthrough, AnsiParserState.Ground, AnsiTransitionAction.DscUnhook); + } + + // 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 + Add(0x9C, AnsiParserState.OscString, AnsiParserState.Ground, AnsiTransitionAction.OscEnd); } } - private void Single( - byte code, AnsiParserState fromState, - AnsiParserState toState, AnsiTransitionAction action) + public AnsiTransition GetTransition( + AnsiParserState state, int code) { - if (!_transitions.TryGetValue(code, out var value)) + if (_transitions.TryGetValue(code, out var lookup) && + lookup.TryGetValue(state, out var transition)) { - value = []; - _transitions.Add(code, value); + return transition; } - if (!value.TryAdd(fromState, new AnsiTransition(toState, action))) + return new AnsiTransition(state, AnsiTransitionAction.None); + } + + private void Add( + byte code, AnsiParserState fromState, + AnsiParserState toState, AnsiTransitionAction action) + { + if (!_transitions.TryGetValue(code, out var lookup)) { - throw new InvalidOperationException("Duplicate transition detected"); + lookup = []; + _transitions.Add(code, lookup); } + + lookup[fromState] = new AnsiTransition(toState, action); } - private void Range( + private void Add( Range codes, AnsiParserState fromState, AnsiParserState toState, AnsiTransitionAction action) { - for (var code = codes.Start.Value; code < codes.End.Value; code++) + for (var code = codes.Start.Value; code < codes.End.Value + 1; code++) { - Single((byte)code, fromState, toState, action); + Add((byte)code, fromState, toState, action); } } -} +} \ No newline at end of file From 71b393c811db2599dd13cad0b4a898d77e077b35 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Tue, 3 Feb 2026 17:24:47 +0100 Subject: [PATCH 03/27] parsing: add raw parameters --- .../AnsiParserTests.cs | 5 +++ src/Spectre.Console.Ansi/AnsiParser.cs | 41 ++++++++----------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 82785057e..a8d2ddb6f 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -30,6 +30,7 @@ public void Csi_Sequence_1() .And(csi => { csi.Collect.Count.ShouldBe(0); + csi.ParamsRaw.ShouldBe(""); csi.Params.Count.ShouldBe(0); csi.Final.ShouldBe('H'); }); @@ -48,6 +49,7 @@ public void Csi_Sequence_2() { csi.Collect.Count.ShouldBe(0); + csi.ParamsRaw.ShouldBe("1;4"); csi.Params.Count.ShouldBe(2); csi.Params[0].ShouldBe(1); csi.Params[1].ShouldBe(4); @@ -69,6 +71,7 @@ public void Csi_Sequence_3() { csi.Collect.Count.ShouldBe(0); + csi.ParamsRaw.ShouldBe("38:2"); csi.Params.Count.ShouldBe(2); csi.Params[0].ShouldBe(38); csi.Params[1].ShouldBe(2); @@ -90,6 +93,7 @@ public void Csi_Sequence_4() { csi.Collect.Count.ShouldBe(0); + csi.ParamsRaw.ShouldBe("38;2"); csi.Params.Count.ShouldBe(2); csi.Params[0].ShouldBe(38); csi.Params[1].ShouldBe(2); @@ -113,6 +117,7 @@ public void Csi_Sequence_5() csi.Collect[0].ShouldBe('?'); csi.Collect[1].ShouldBe('$'); + csi.ParamsRaw.ShouldBe("2026"); csi.Params.Count.ShouldBe(1); csi.Params[0].ShouldBe(2026); diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 74614e091..4e866cf73 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -6,7 +6,9 @@ public sealed class AnsiParser private readonly List _collect = []; private readonly List _osc = []; private readonly List _parameters = [0]; - private bool _hasParameter = false; + private readonly List _parameterSeparators = [true]; + private readonly StringBuilder _parametersRaw = new(); + private bool _hasParameter; private AnsiParserState _currentState; public AnsiParser(Action callback) @@ -40,19 +42,19 @@ public void Next(char code) _collect.Add(code); break; case AnsiTransitionAction.Param: + _parametersRaw.Append(code); + if (code is ';' or ':') { _parameters.Add(0); + _parameterSeparators.Add(code is ';'); } else { - if (!char.IsDigit(code)) - { - // TODO: Handle this - throw new InvalidOperationException(""); - } + Debug.Assert(char.IsDigit(code), "Expected digit"); var accumulator = (_parameters[^1] * 10) + code - 48; + _parameters[^1] = accumulator > (int.MaxValue / 10) - 10 ? 0 @@ -69,9 +71,12 @@ public void Next(char code) _callback(new AnsiToken.Csi( [.. _collect], _hasParameter ? [.. _parameters] : [], - code)); + code, + _parametersRaw.ToString())); break; case AnsiTransitionAction.Clear: + _hasParameter = false; + _parametersRaw.Clear(); _parameters.Clear(); _parameters.Add(0); _osc.Clear(); @@ -91,20 +96,18 @@ public void Next(char code) // Learned about CAN/SUB from SwiftTerm, but not sure why... if (_osc.Count > 0 && _osc[0] != 0x18 /*CAN*/ && _osc[0] != 0x1A /*SUB*/) { - var oscCode = 0; + int oscCode; var content = string.Empty; var osc = new string(_osc.ToArray()); var idx = osc.IndexOf(';'); if (idx != -1) { - // TODO: Fix parsing oscCode = int.Parse(osc[..idx]); - content = osc.Substring(idx, osc.Length - idx); + content = osc[idx..]; } else { - // TODO: Fix parsing oscCode = int.Parse(osc); } @@ -129,27 +132,17 @@ public void Next(char code) _currentState = nextState; } } - - private void DispatchEsc(char c) - { - if (_collect.Count == 0) - { - } - else if (_collect.Count == 1) - { - } - } } public abstract record AnsiToken { - public record Print(char C) : AnsiToken; + public record Print(char Code) : AnsiToken; - public record Execute(char C) : AnsiToken; + public record Execute(char Code) : AnsiToken; public record Esc(List Collect, char Final) : AnsiToken; - public record Csi(List Collect, List Params, char Final) : AnsiToken; + public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; public record Osc(char Code, List Data) : AnsiToken; } From ff0388ceed7e41801de7f7f465dc2a9e0e4636b4 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sat, 21 Mar 2026 22:19:10 +0100 Subject: [PATCH 04/27] Minor changes --- .../AnsiParserTests.cs | 10 + src/Spectre.Console.Ansi/AnsiParser.cs | 251 ++++++++++-------- 2 files changed, 156 insertions(+), 105 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index a8d2ddb6f..a8324af25 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -124,6 +124,16 @@ public void Csi_Sequence_5() csi.Final.ShouldBe('p'); }); } + + [Fact(DisplayName = "osc: ESC [ ? 2026 $ p")] + public void Osc_Sequence_1() + { + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=123;https://spectreconsole.net\e\\\e[1;3m\e[38;5;11mSpectre Console\e[0m\e]8;;\e\\"); + + // Then + result.Count.ShouldBe(20); + } } internal sealed class AnsiParserFixture diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 4e866cf73..19661d236 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -1,5 +1,8 @@ namespace Spectre.Console.Ansi; +/// +/// A ANSI/VT input parser based on the VT500-series. +/// public sealed class AnsiParser { private readonly Action _callback; @@ -11,139 +14,180 @@ public sealed class AnsiParser private bool _hasParameter; 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; } + /// + /// Processes the specified code. + /// + /// The code to process. public void Next(char code) { - var effect = AnsiTransitionTable.Shared.GetTransition(_currentState, code); + var (nextState, action) = AnsiTransitionTable.Shared.GetTransition(_currentState, code); - var nextState = effect.State; - var action = effect.Action; - - try + // Perform entry event + if (_currentState != nextState) { - switch (action) + switch (_currentState) { - case AnsiTransitionAction.None: - case AnsiTransitionAction.Ignore: - // Do nothing - break; - case AnsiTransitionAction.Print: - _callback(new AnsiToken.Print(code)); - break; - case AnsiTransitionAction.Execute: - _callback(new AnsiToken.Execute(code)); - break; - case AnsiTransitionAction.Collect: - _collect.Add(code); + case AnsiParserState.OscString: + EmitOscEnd(); break; - case AnsiTransitionAction.Param: - _parametersRaw.Append(code); - - if (code is ';' or ':') - { - _parameters.Add(0); - _parameterSeparators.Add(code is ';'); - } - else - { - Debug.Assert(char.IsDigit(code), "Expected digit"); - - var accumulator = (_parameters[^1] * 10) + code - 48; - - _parameters[^1] = - accumulator > (int.MaxValue / 10) - 10 - ? 0 - : accumulator; - - _hasParameter = true; - } + } + } - break; - case AnsiTransitionAction.EscDispatch: - _callback(new AnsiToken.Esc([.. _collect], code)); - break; - case AnsiTransitionAction.CsiDispatch: - _callback(new AnsiToken.Csi( - [.. _collect], - _hasParameter ? [.. _parameters] : [], - code, - _parametersRaw.ToString())); - break; - case AnsiTransitionAction.Clear: - _hasParameter = false; - _parametersRaw.Clear(); - _parameters.Clear(); + // Perform the action + switch (action) + { + case AnsiTransitionAction.None: + case AnsiTransitionAction.Ignore: + // Do nothing + break; + case AnsiTransitionAction.Print: + _callback(new AnsiToken.Print(code)); + break; + case AnsiTransitionAction.Execute: + _callback(new AnsiToken.Execute(code)); + break; + case AnsiTransitionAction.Collect: + _collect.Add(code); + break; + case AnsiTransitionAction.Param: + _parametersRaw.Append(code); + + if (code is ';' or ':') + { _parameters.Add(0); - _osc.Clear(); - _collect.Clear(); - break; - case AnsiTransitionAction.OscStart: - _osc.Clear(); - break; - case AnsiTransitionAction.OscPut: - if (code >= 0x20) - { - _osc.Add(code); - } + _parameterSeparators.Add(code is ';'); + } + else + { + Debug.Assert(char.IsDigit(code), "Expected digit"); + + var accumulator = (_parameters[^1] * 10) + code - 48; + _parameters[^1] = accumulator > (int.MaxValue / 10) - 10 ? 0 : accumulator; + _hasParameter = true; + } + + break; + case AnsiTransitionAction.EscDispatch: + _callback(new AnsiToken.Esc( + Collect: [.. _collect], + Final: code)); + break; + case AnsiTransitionAction.CsiDispatch: + _callback(new AnsiToken.Csi( + Collect: [.. _collect], + Params: _hasParameter ? [.. _parameters] : [], + Final: code, + ParamsRaw: _parametersRaw.ToString())); + break; + case AnsiTransitionAction.Clear: + _hasParameter = false; + _parametersRaw.Clear(); + _parameters.Clear(); + _parameters.Add(0); + _osc.Clear(); + _collect.Clear(); + break; + case AnsiTransitionAction.OscStart: + _osc.Clear(); + break; + case AnsiTransitionAction.OscPut: + if (code >= 0x20) + { + _osc.Add(code); + } + + break; + case AnsiTransitionAction.OscEnd: + EmitOscEnd(); + break; + case AnsiTransitionAction.DscHook: + case AnsiTransitionAction.DscPut: + case AnsiTransitionAction.DscUnhook: + // Ignore DSC for now + break; + default: + throw new ArgumentOutOfRangeException(); + } - break; - case AnsiTransitionAction.OscEnd: - // Learned about CAN/SUB from SwiftTerm, but not sure why... - if (_osc.Count > 0 && _osc[0] != 0x18 /*CAN*/ && _osc[0] != 0x1A /*SUB*/) - { - int oscCode; - var content = string.Empty; - - var osc = new string(_osc.ToArray()); - var idx = osc.IndexOf(';'); - if (idx != -1) - { - oscCode = int.Parse(osc[..idx]); - content = osc[idx..]; - } - else - { - oscCode = int.Parse(osc); - } - - _callback( - new AnsiToken.Osc( - (char)oscCode, - [.. content.ToArray()])); - } + _currentState = nextState; + } - break; - case AnsiTransitionAction.DscHook: - case AnsiTransitionAction.DscPut: - case AnsiTransitionAction.DscUnhook: - // Ignore DSC for now - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - finally + private void EmitOscEnd() + { + // Learned about CAN/SUB from SwiftTerm, but not sure why... + if (_osc.Count > 0 && _osc[0] != 0x18 /*CAN*/ && _osc[0] != 0x1A /*SUB*/) { - _currentState = nextState; + int oscCode; + var content = string.Empty; + + var osc = new string(_osc.ToArray()); + var idx = osc.IndexOf(';'); + if (idx != -1) + { + oscCode = int.Parse(osc[..idx]); + content = osc[idx..]; + } + else + { + oscCode = int.Parse(osc); + } + + _callback( + new AnsiToken.Osc( + Code: (char)oscCode, + Data: [.. content.ToArray()])); } } } +/// +/// Represents a parsed ANSI/VT token. +/// public abstract record AnsiToken { + /// + /// Prints a (unicode codepoint) character to the screen. + /// + /// public record Print(char Code) : AnsiToken; + /// + /// Executes the C0 or C1 function. + /// + /// public record Execute(char Code) : AnsiToken; + /// + /// Execute an ESC command. + /// + /// + /// public record Esc(List Collect, char Final) : AnsiToken; + /// + /// Executes a CSI command. + /// + /// + /// + /// + /// public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + /// + /// Executes a OSC command. + /// + /// + /// public record Osc(char Code, List Data) : AnsiToken; } @@ -462,9 +506,6 @@ private AnsiTransitionTable() 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 - Add(0x9C, AnsiParserState.OscString, AnsiParserState.Ground, AnsiTransitionAction.OscEnd); } } From a9a383201d73ec286f4cd92f0f2957938a60665c Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 00:43:37 +0100 Subject: [PATCH 05/27] Do explicit entry/exit. Add OSC parser --- .../AnsiParserTests.cs | 4 +- .../{ => Parsing}/AnsiParser.cs | 177 +++++++----------- src/Spectre.Console.Ansi/Parsing/AnsiToken.cs | 51 +++++ .../Parsing/OscCommand.cs | 12 ++ src/Spectre.Console.Ansi/Parsing/OscParser.cs | 134 +++++++++++++ 5 files changed, 264 insertions(+), 114 deletions(-) rename src/Spectre.Console.Ansi/{ => Parsing}/AnsiParser.cs (85%) create mode 100644 src/Spectre.Console.Ansi/Parsing/AnsiToken.cs create mode 100644 src/Spectre.Console.Ansi/Parsing/OscCommand.cs create mode 100644 src/Spectre.Console.Ansi/Parsing/OscParser.cs diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index a8324af25..7343e9b86 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -129,10 +129,10 @@ public void Csi_Sequence_5() public void Osc_Sequence_1() { // Given, When - var result = AnsiParserFixture.Parse("\e]8;id=123;https://spectreconsole.net\e\\\e[1;3m\e[38;5;11mSpectre Console\e[0m\e]8;;\e\\"); + var result = AnsiParserFixture.Parse("\e]8;;http://example.com\e\\TEXT\e]8;;\e\\"); // Then - result.Count.ShouldBe(20); + result.Count.ShouldBe(22); } } diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/Parsing/AnsiParser.cs similarity index 85% rename from src/Spectre.Console.Ansi/AnsiParser.cs rename to src/Spectre.Console.Ansi/Parsing/AnsiParser.cs index 19661d236..d1b44e1b1 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/Parsing/AnsiParser.cs @@ -7,10 +7,10 @@ public sealed class AnsiParser { private readonly Action _callback; private readonly List _collect = []; - private readonly List _osc = []; private readonly List _parameters = [0]; private readonly List _parameterSeparators = [true]; private readonly StringBuilder _parametersRaw = new(); + private readonly OscParser _oscParser; private bool _hasParameter; private AnsiParserState _currentState; @@ -22,6 +22,7 @@ public AnsiParser(Action callback) { _callback = callback ?? throw new ArgumentNullException(nameof(callback)); _currentState = AnsiParserState.Ground; + _oscParser = new OscParser(); } /// @@ -38,7 +39,17 @@ public void Next(char code) switch (_currentState) { case AnsiParserState.OscString: - EmitOscEnd(); + var command = _oscParser.End(code); + if (command != null) + { + _callback(new AnsiToken.Osc(command)); + } + break; + case AnsiParserState.DcsPassthrough: + _callback(new AnsiToken.DcsUnhook()); + break; + case AnsiParserState.SosPmApcString: + _callback(new AnsiToken.ApcEnd()); break; } } @@ -89,106 +100,56 @@ public void Next(char code) Final: code, ParamsRaw: _parametersRaw.ToString())); break; - case AnsiTransitionAction.Clear: - _hasParameter = false; - _parametersRaw.Clear(); - _parameters.Clear(); - _parameters.Add(0); - _osc.Clear(); - _collect.Clear(); - break; - case AnsiTransitionAction.OscStart: - _osc.Clear(); - break; case AnsiTransitionAction.OscPut: - if (code >= 0x20) - { - _osc.Add(code); - } - + _oscParser.Next(code); break; - case AnsiTransitionAction.OscEnd: - EmitOscEnd(); - break; - case AnsiTransitionAction.DscHook: case AnsiTransitionAction.DscPut: - case AnsiTransitionAction.DscUnhook: - // Ignore DSC for now + _callback(new AnsiToken.DcsPut(code)); + break; + case AnsiTransitionAction.ApcPut: + _callback(new AnsiToken.ApcPut(code)); break; default: throw new ArgumentOutOfRangeException(); } - _currentState = nextState; - } - - private void EmitOscEnd() - { - // Learned about CAN/SUB from SwiftTerm, but not sure why... - if (_osc.Count > 0 && _osc[0] != 0x18 /*CAN*/ && _osc[0] != 0x1A /*SUB*/) + // Perform exit event + if (_currentState != nextState) { - int oscCode; - var content = string.Empty; - - var osc = new string(_osc.ToArray()); - var idx = osc.IndexOf(';'); - if (idx != -1) - { - oscCode = int.Parse(osc[..idx]); - content = osc[idx..]; - } - else + switch (nextState) { - oscCode = int.Parse(osc); + case AnsiParserState.Escape: + case AnsiParserState.DcsEntry: + case AnsiParserState.CsiEntry: + Clear(); + break; + case AnsiParserState.OscString: + _oscParser.Reset(); + break; + case AnsiParserState.DcsPassthrough: + _callback(new AnsiToken.Csi( + Collect: [.. _collect], + Params: _hasParameter ? [.. _parameters] : [], + Final: code, + ParamsRaw: _parametersRaw.ToString())); + break; + case AnsiParserState.SosPmApcString: + _callback(new AnsiToken.ApcStart()); + break; } - - _callback( - new AnsiToken.Osc( - Code: (char)oscCode, - Data: [.. content.ToArray()])); } - } -} - -/// -/// Represents a parsed ANSI/VT token. -/// -public abstract record AnsiToken -{ - /// - /// Prints a (unicode codepoint) character to the screen. - /// - /// - public record Print(char Code) : AnsiToken; - /// - /// Executes the C0 or C1 function. - /// - /// - public record Execute(char Code) : AnsiToken; - - /// - /// Execute an ESC command. - /// - /// - /// - public record Esc(List Collect, char Final) : AnsiToken; - - /// - /// Executes a CSI command. - /// - /// - /// - /// - /// - public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + _currentState = nextState; + } - /// - /// Executes a OSC command. - /// - /// - /// - public record Osc(char Code, List Data) : AnsiToken; + private void Clear() + { + _hasParameter = false; + _parametersRaw.Clear(); + _parameters.Clear(); + _parameters.Add(0); + _collect.Clear(); + } } internal enum AnsiParserState @@ -219,13 +180,9 @@ internal enum AnsiTransitionAction Param, EscDispatch, CsiDispatch, - DscPut, - Clear, - OscStart, OscPut, - OscEnd, - DscHook, - DscUnhook, + DscPut, + ApcPut, } internal readonly record struct AnsiTransition( @@ -264,16 +221,16 @@ private AnsiTransitionTable() Add(0x9F, state, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); // -> Escape - Add(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.Clear); + Add(0x1B, state, AnsiParserState.Escape, AnsiTransitionAction.None); // -> DcsEntry - Add(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.Clear); + Add(0x90, state, AnsiParserState.DcsEntry, AnsiTransitionAction.None); // -> OscString - Add(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.OscStart); + Add(0x9D, state, AnsiParserState.OscString, AnsiTransitionAction.None); // -> CsiEntry - Add(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.Clear); + Add(0x9B, state, AnsiParserState.CsiEntry, AnsiTransitionAction.None); } // Ground @@ -326,24 +283,24 @@ private AnsiTransitionTable() Add(0x5F, AnsiParserState.Escape, AnsiParserState.SosPmApcString, AnsiTransitionAction.None); // -> DcsEntry - Add(0x50, AnsiParserState.Escape, AnsiParserState.DcsEntry, AnsiTransitionAction.Clear); + Add(0x50, AnsiParserState.Escape, AnsiParserState.DcsEntry, AnsiTransitionAction.None); // -> OscString - Add(0x5D, AnsiParserState.Escape, AnsiParserState.OscString, AnsiTransitionAction.OscStart); + Add(0x5D, AnsiParserState.Escape, AnsiParserState.OscString, AnsiTransitionAction.None); // -> CsiEntry - Add(0x5B, AnsiParserState.Escape, AnsiParserState.CsiEntry, AnsiTransitionAction.Clear); + Add(0x5B, AnsiParserState.Escape, AnsiParserState.CsiEntry, AnsiTransitionAction.None); } // SosPmApcString { - Add(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); + Add(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.ApcPut); Add(0x00..0x17, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.Ignore); + AnsiTransitionAction.ApcPut); Add(0x1C..0x1F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.Ignore); + AnsiTransitionAction.ApcPut); Add(0x20..0x7F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.Ignore); + AnsiTransitionAction.ApcPut); } // DcsEntry @@ -365,7 +322,7 @@ private AnsiTransitionTable() Add(0x3C..0x3F, AnsiParserState.DcsEntry, AnsiParserState.DcsParam, AnsiTransitionAction.Collect); // -> DcsPassthrough - Add(0x40..0x7E, AnsiParserState.DcsEntry, AnsiParserState.DcsPassthrough, AnsiTransitionAction.DscHook); + Add(0x40..0x7E, AnsiParserState.DcsEntry, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); } // DcsIntermediate @@ -381,8 +338,7 @@ private AnsiTransitionTable() Add(0x30..0x3F, AnsiParserState.DcsIntermediate, AnsiParserState.DcsIgnore, AnsiTransitionAction.None); // -> DcsPassthrough - Add(0x40..0x7E, AnsiParserState.DcsIntermediate, AnsiParserState.DcsPassthrough, - AnsiTransitionAction.DscHook); + Add(0x40..0x7E, AnsiParserState.DcsIntermediate, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); } // DcsIgnore @@ -410,7 +366,7 @@ private AnsiTransitionTable() Add(0x20..0x2F, AnsiParserState.DcsParam, AnsiParserState.DcsIntermediate, AnsiTransitionAction.Collect); // -> DcsPassthrough - Add(0x40..0x7E, AnsiParserState.DcsParam, AnsiParserState.DcsPassthrough, AnsiTransitionAction.DscHook); + Add(0x40..0x7E, AnsiParserState.DcsParam, AnsiParserState.DcsPassthrough, AnsiTransitionAction.None); } // DcsPassthrough @@ -423,9 +379,6 @@ private AnsiTransitionTable() Add(0x20..0x7E, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, AnsiTransitionAction.DscPut); Add(0x7F, AnsiParserState.DcsPassthrough, AnsiParserState.DcsPassthrough, AnsiTransitionAction.Ignore); - - // -> Ground - Add(0x9C, AnsiParserState.DcsPassthrough, AnsiParserState.Ground, AnsiTransitionAction.DscUnhook); } // CsiParam diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs new file mode 100644 index 000000000..ed2774d91 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs @@ -0,0 +1,51 @@ +namespace Spectre.Console.Ansi; + +/// +/// Represents a parsed ANSI/VT token. +/// +public abstract record AnsiToken +{ + /// + /// Prints a (Unicode codepoint) character to the screen. + /// + /// + public record Print(char Code) : AnsiToken; + + /// + /// Executes the C0 or C1 function. + /// + /// + public record Execute(char Code) : AnsiToken; + + /// + /// Execute an ESC command. + /// + /// + /// + public record Esc(List Collect, char Final) : AnsiToken; + + /// + /// Executes a CSI command. + /// + /// + /// + /// + /// + public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + + public record Osc(OscCommand command) : AnsiToken; + + public record OscOld(char Code, List Data) : AnsiToken; + + public record DcsHook(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + + public record DcsPut(char Code) : AnsiToken; + + public record DcsUnhook() : AnsiToken; + + public record ApcStart : AnsiToken; + + public record ApcEnd() : AnsiToken; + + public record ApcPut(char Code) : AnsiToken; +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/OscCommand.cs b/src/Spectre.Console.Ansi/Parsing/OscCommand.cs new file mode 100644 index 000000000..bbb50793a --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/OscCommand.cs @@ -0,0 +1,12 @@ +namespace Spectre.Console.Ansi; + +public record OscCommand +{ + public record HyperLinkStart(string? Id, string Url) : OscCommand + { + } + + public record HyperLinkEnd : OscCommand + { + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/OscParser.cs b/src/Spectre.Console.Ansi/Parsing/OscParser.cs new file mode 100644 index 000000000..fe7a6f762 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/OscParser.cs @@ -0,0 +1,134 @@ +namespace Spectre.Console.Ansi; + +internal enum OscParserState +{ + Invalid = 0, + Start = 1, + Osc8 = 2, +} + +internal sealed class OscParser +{ + private char[] _buffer = new char[2048]; + private int _index = 0; + private bool _writeToBuffer = false; + private OscParserState _state = OscParserState.Start; + + public void Reset() + { + _buffer = new char[2048]; + _index = 0; + _writeToBuffer = false; + _state = OscParserState.Start; + } + + public void Next(char code) + { + if (_state == OscParserState.Invalid) + { + return; + } + + if (_writeToBuffer) + { + _buffer[_index] = code; + _index++; + return; + } + + switch (_state) + { + case OscParserState.Invalid: + break; + case OscParserState.Start: + switch (code) + { + case '8': + _state = OscParserState.Osc8; + break; + default: + _state = OscParserState.Invalid; + break; + } + + break; + case OscParserState.Osc8: + switch (code) + { + case ';': + _writeToBuffer = true; + break; + default: + _state = OscParserState.Invalid; + break; + } + + break; + } + } + + public OscCommand? End(char terminator) + { + if (_state == OscParserState.Osc8) + { + return OscHyperLinkParser.Parse(_buffer.AsSpan(0, _index)); + } + + return null; + } +} + +file static class OscHyperLinkParser +{ + public static OscCommand? Parse(ReadOnlySpan input) + { + string? id = null; + + // Find the final ';' which separates params from the URI. + var lastSemicolon = input.LastIndexOf(';'); + if (lastSemicolon < 0) + { + return new OscCommand.HyperLinkStart(null, input.ToString()); + } + + var parameters = input[..lastSemicolon]; + var uri = input[(lastSemicolon + 1)..]; + + // Parse key=value pairs from the params section. + while (!parameters.IsEmpty) + { + var equalsIndex = parameters.IndexOf('='); + if (equalsIndex < 0) + { + break; + } + + var key = parameters[..equalsIndex].Trim(); + parameters = parameters[(equalsIndex + 1)..]; + + var semicolonIndex = parameters.IndexOf(';'); + var value = semicolonIndex < 0 + ? parameters.Trim() + : parameters[..semicolonIndex].Trim(); + + if (key.SequenceEqual("id")) + { + id = value.IsEmpty ? null : value.ToString(); + } + + if (semicolonIndex < 0) + { + break; + } + + parameters = parameters[(semicolonIndex + 1)..]; + } + + if (uri.Length == 0) + { + return new OscCommand.HyperLinkEnd(); + } + + return new OscCommand.HyperLinkStart(id, uri.ToString()); + } +} \ No newline at end of file From b1760ae1e44d61110c84566bb53999503a583664 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 01:02:39 +0100 Subject: [PATCH 06/27] More tests --- .../AnsiParserTests.cs | 110 +++++++++++++++--- .../Fixtures/AnsiParserFixture.cs | 17 +++ .../Utilities/ShouldlyExtensions.cs | 14 +++ src/Spectre.Console.Ansi/Parsing/AnsiToken.cs | 4 +- .../Parsing/{ => Osc}/OscCommand.cs | 0 .../Parsing/Osc/OscHyperLinkParser.cs | 58 +++++++++ .../Parsing/{ => Osc}/OscParser.cs | 55 --------- 7 files changed, 186 insertions(+), 72 deletions(-) create mode 100644 src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs rename src/Spectre.Console.Ansi/Parsing/{ => Osc}/OscCommand.cs (100%) create mode 100644 src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs rename src/Spectre.Console.Ansi/Parsing/{ => Osc}/OscParser.cs (54%) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 7343e9b86..7602cb8a4 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -125,29 +125,111 @@ public void Csi_Sequence_5() }); } - [Fact(DisplayName = "osc: ESC [ ? 2026 $ p")] + [Fact(DisplayName = "osc 8: Hyperlink")] public void Osc_Sequence_1() { // Given, When - var result = AnsiParserFixture.Parse("\e]8;;http://example.com\e\\TEXT\e]8;;\e\\"); + var result = AnsiParserFixture.Parse("\e]8;;https://example.com\e\\"); // Then - result.Count.ShouldBe(22); + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Url.ShouldBe("https://example.com"); + }); } -} -internal sealed class AnsiParserFixture -{ - public static List Parse(string text) + [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.Url.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty ID")] + public void Osc_Sequence_3() { - var result = new List(); - var parser = new AnsiParser(token => result.Add(token)); + // Given, When + var result = AnsiParserFixture.Parse("\e]8;id=;https://example.com\e\\"); - foreach (var character in text) - { - parser.Next(character); - } + // Then + result.Count.ShouldBe(2); + result[0].ShouldBeOfType() + .And().Command.ShouldBeOfType() + .And(osc => + { + osc.Id.ShouldBeNull(); + osc.Url.ShouldBe("https://example.com"); + }); + } - return result; + [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.Url.ShouldBe("https://example.com"); + }); + } + + [Fact(DisplayName = "osc 8: Hyperlink with empty key and 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.Url.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(); } } \ 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..7a4d2f40f --- /dev/null +++ b/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs @@ -0,0 +1,17 @@ +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)); + + foreach (var character in text) + { + parser.Next(character); + } + + 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 index c3fe5dac9..bd6f9c53a 100644 --- a/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs +++ b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs @@ -7,6 +7,20 @@ namespace Spectre.Console.Ansi.Tests; /// public static class ShouldlyExtensions { + /// + /// 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. + /// 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 diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs index ed2774d91..b2c248cc1 100644 --- a/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs +++ b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs @@ -33,9 +33,7 @@ public record Esc(List Collect, char Final) : AnsiToken; /// public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; - public record Osc(OscCommand command) : AnsiToken; - - public record OscOld(char Code, List Data) : AnsiToken; + public record Osc(OscCommand Command) : AnsiToken; public record DcsHook(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; diff --git a/src/Spectre.Console.Ansi/Parsing/OscCommand.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/OscCommand.cs rename to src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs 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..cc374caa1 --- /dev/null +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs @@ -0,0 +1,58 @@ +namespace Spectre.Console.Ansi; + +internal static class OscHyperLinkParser +{ + public static OscCommand? Parse(ReadOnlySpan buffer) + { + string? id = null; + + // Find the final ';' which separates params from the URI. + var lastSemicolon = buffer.LastIndexOf(';'); + if (lastSemicolon < 0) + { + return new OscCommand.HyperLinkStart(null, buffer.ToString()); + } + + var parameters = buffer[..lastSemicolon]; + var uri = buffer[(lastSemicolon + 1)..]; + + // Parse key=value pairs from the params section. + while (!parameters.IsEmpty) + { + var equalsIndex = parameters.IndexOf('='); + if (equalsIndex < 0) + { + break; + } + + var key = parameters[..equalsIndex].Trim(); + parameters = parameters[(equalsIndex + 1)..]; + + var semicolonIndex = parameters.IndexOf(';'); + var value = semicolonIndex < 0 + ? parameters.Trim() + : parameters[..semicolonIndex].Trim(); + + if (key.SequenceEqual("id")) + { + id = value.IsEmpty ? null : value.ToString(); + } + + if (semicolonIndex < 0) + { + break; + } + + parameters = parameters[(semicolonIndex + 1)..]; + } + + if (id == null && uri.Length == 0) + { + return new OscCommand.HyperLinkEnd(); + } + + return uri.Length == 0 + ? null + : new OscCommand.HyperLinkStart(id, uri.ToString()); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Parsing/OscParser.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs similarity index 54% rename from src/Spectre.Console.Ansi/Parsing/OscParser.cs rename to src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs index fe7a6f762..2c83ac609 100644 --- a/src/Spectre.Console.Ansi/Parsing/OscParser.cs +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs @@ -76,59 +76,4 @@ public void Next(char code) return null; } -} - -file static class OscHyperLinkParser -{ - public static OscCommand? Parse(ReadOnlySpan input) - { - string? id = null; - - // Find the final ';' which separates params from the URI. - var lastSemicolon = input.LastIndexOf(';'); - if (lastSemicolon < 0) - { - return new OscCommand.HyperLinkStart(null, input.ToString()); - } - - var parameters = input[..lastSemicolon]; - var uri = input[(lastSemicolon + 1)..]; - - // Parse key=value pairs from the params section. - while (!parameters.IsEmpty) - { - var equalsIndex = parameters.IndexOf('='); - if (equalsIndex < 0) - { - break; - } - - var key = parameters[..equalsIndex].Trim(); - parameters = parameters[(equalsIndex + 1)..]; - - var semicolonIndex = parameters.IndexOf(';'); - var value = semicolonIndex < 0 - ? parameters.Trim() - : parameters[..semicolonIndex].Trim(); - - if (key.SequenceEqual("id")) - { - id = value.IsEmpty ? null : value.ToString(); - } - - if (semicolonIndex < 0) - { - break; - } - - parameters = parameters[(semicolonIndex + 1)..]; - } - - if (uri.Length == 0) - { - return new OscCommand.HyperLinkEnd(); - } - - return new OscCommand.HyperLinkStart(id, uri.ToString()); - } } \ No newline at end of file From 5ec6c45b1181f4440a19fefb482b802658ea2d57 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 01:11:08 +0100 Subject: [PATCH 07/27] More tests --- .../AnsiParserTests.cs | 20 +++++++++++++++++++ .../Parsing/Osc/OscCommand.cs | 9 ++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 7602cb8a4..4e13f3b22 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -232,4 +232,24 @@ public void Osc_Sequence_7() 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(); + } } \ 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 index bbb50793a..f53a93a5d 100644 --- a/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs +++ b/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs @@ -2,11 +2,6 @@ namespace Spectre.Console.Ansi; public record OscCommand { - public record HyperLinkStart(string? Id, string Url) : OscCommand - { - } - - public record HyperLinkEnd : OscCommand - { - } + public record HyperLinkStart(string? Id, string Url) : OscCommand; + public record HyperLinkEnd : OscCommand; } \ No newline at end of file From f58468cfbdde62752af9b19678a3447de77b62cd Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 12:54:54 +0100 Subject: [PATCH 08/27] Add Ghostty --- src/Spectre.Console.Ansi/AnsiDetector.cs | 3 ++- src/Spectre.Console.Ansi/{Parsing => }/AnsiParser.cs | 0 src/Spectre.Console.Ansi/{Parsing => }/AnsiToken.cs | 0 src/Spectre.Console.Ansi/{Parsing => }/Osc/OscCommand.cs | 0 .../{Parsing => }/Osc/OscHyperLinkParser.cs | 0 src/Spectre.Console.Ansi/{Parsing => }/Osc/OscParser.cs | 0 6 files changed, 2 insertions(+), 1 deletion(-) rename src/Spectre.Console.Ansi/{Parsing => }/AnsiParser.cs (100%) rename src/Spectre.Console.Ansi/{Parsing => }/AnsiToken.cs (100%) rename src/Spectre.Console.Ansi/{Parsing => }/Osc/OscCommand.cs (100%) rename src/Spectre.Console.Ansi/{Parsing => }/Osc/OscHyperLinkParser.cs (100%) rename src/Spectre.Console.Ansi/{Parsing => }/Osc/OscParser.cs (100%) 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/AnsiParser.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/AnsiParser.cs rename to src/Spectre.Console.Ansi/AnsiParser.cs diff --git a/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs b/src/Spectre.Console.Ansi/AnsiToken.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/AnsiToken.cs rename to src/Spectre.Console.Ansi/AnsiToken.cs diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Osc/OscCommand.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs rename to src/Spectre.Console.Ansi/Osc/OscCommand.cs diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs rename to src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs diff --git a/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs similarity index 100% rename from src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs rename to src/Spectre.Console.Ansi/Osc/OscParser.cs From e228821846af7d5119928b9bee44b6900db4fc63 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 12:55:06 +0100 Subject: [PATCH 09/27] Clean up --- .../AnsiParserTests.cs | 2 +- src/Spectre.Console.Ansi/AnsiParser.cs | 2 +- src/Spectre.Console.Ansi/AnsiToken.cs | 34 +++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 4e13f3b22..7abae5f98 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -193,7 +193,7 @@ public void Osc_Sequence_4() }); } - [Fact(DisplayName = "osc 8: Hyperlink with empty key and id set")] + [Fact(DisplayName = "osc 8: Hyperlink with empty key but id set")] public void Osc_Sequence_5() { // Given, When diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index d1b44e1b1..fa3c45d05 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -127,7 +127,7 @@ public void Next(char code) _oscParser.Reset(); break; case AnsiParserState.DcsPassthrough: - _callback(new AnsiToken.Csi( + _callback(new AnsiToken.DcsHook( Collect: [.. _collect], Params: _hasParameter ? [.. _parameters] : [], Final: code, diff --git a/src/Spectre.Console.Ansi/AnsiToken.cs b/src/Spectre.Console.Ansi/AnsiToken.cs index b2c248cc1..0ca1242c4 100644 --- a/src/Spectre.Console.Ansi/AnsiToken.cs +++ b/src/Spectre.Console.Ansi/AnsiToken.cs @@ -12,7 +12,7 @@ public abstract record AnsiToken public record Print(char Code) : AnsiToken; /// - /// Executes the C0 or C1 function. + /// Executes the specified function. /// /// public record Execute(char Code) : AnsiToken; @@ -33,17 +33,45 @@ public record Esc(List Collect, char Final) : AnsiToken; /// public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + /// + /// Executes an OSC command. + /// + /// The command to execute. public record Osc(OscCommand Command) : AnsiToken; + /// + /// Sets up a DCS handler. + /// + /// + /// + /// + /// public record DcsHook(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + /// + /// Passes a device control string (DCS) to the selected handler. + /// + /// public record DcsPut(char Code) : AnsiToken; + /// + /// Unselects the current specified DCS handler. + /// public record DcsUnhook() : AnsiToken; + /// + /// Begins an APC sequence. + /// public record ApcStart : AnsiToken; - public record ApcEnd() : AnsiToken; - + /// + /// Appends the specified code to the current APC sequence. + /// + /// public record ApcPut(char Code) : AnsiToken; + + /// + /// Ends an APC sequence. + /// + public record ApcEnd() : AnsiToken; } \ No newline at end of file From be7fd0b037f7bbed65a1b4c6228109a9b37e59ec Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 13:29:08 +0100 Subject: [PATCH 10/27] Remove APC. Fix some things --- .../AnsiParserTests.cs | 26 ++++----- src/Spectre.Console.Ansi/AnsiParser.cs | 44 +++++---------- src/Spectre.Console.Ansi/AnsiToken.cs | 56 +++++++------------ src/Spectre.Console.Ansi/Osc/OscCommand.cs | 14 ++++- .../Osc/OscHyperLinkParser.cs | 8 +-- src/Spectre.Console.Ansi/Osc/OscParser.cs | 34 +++++------ 6 files changed, 82 insertions(+), 100 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 7abae5f98..d5ff9ad8c 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -13,7 +13,7 @@ public void Esc_Sequence_1() result[0].ShouldBeOfType() .And(esc => { - esc.Collect.ShouldBe(['(']); + esc.Intermediates.ShouldBe(['(']); esc.Final.ShouldBe('B'); }); } @@ -29,7 +29,7 @@ public void Csi_Sequence_1() result[0].ShouldBeOfType() .And(csi => { - csi.Collect.Count.ShouldBe(0); + csi.Intermediates.Count.ShouldBe(0); csi.ParamsRaw.ShouldBe(""); csi.Params.Count.ShouldBe(0); csi.Final.ShouldBe('H'); @@ -47,7 +47,7 @@ public void Csi_Sequence_2() result[0].ShouldBeOfType() .And(csi => { - csi.Collect.Count.ShouldBe(0); + csi.Intermediates.Count.ShouldBe(0); csi.ParamsRaw.ShouldBe("1;4"); csi.Params.Count.ShouldBe(2); @@ -69,7 +69,7 @@ public void Csi_Sequence_3() result[0].ShouldBeOfType() .And(csi => { - csi.Collect.Count.ShouldBe(0); + csi.Intermediates.Count.ShouldBe(0); csi.ParamsRaw.ShouldBe("38:2"); csi.Params.Count.ShouldBe(2); @@ -91,7 +91,7 @@ public void Csi_Sequence_4() result[0].ShouldBeOfType() .And(csi => { - csi.Collect.Count.ShouldBe(0); + csi.Intermediates.Count.ShouldBe(0); csi.ParamsRaw.ShouldBe("38;2"); csi.Params.Count.ShouldBe(2); @@ -113,9 +113,9 @@ public void Csi_Sequence_5() result[0].ShouldBeOfType() .And(csi => { - csi.Collect.Count.ShouldBe(2); - csi.Collect[0].ShouldBe('?'); - csi.Collect[1].ShouldBe('$'); + csi.Intermediates.Count.ShouldBe(2); + csi.Intermediates[0].ShouldBe('?'); + csi.Intermediates[1].ShouldBe('$'); csi.ParamsRaw.ShouldBe("2026"); csi.Params.Count.ShouldBe(1); @@ -138,7 +138,7 @@ public void Osc_Sequence_1() .And(osc => { osc.Id.ShouldBeNull(); - osc.Url.ShouldBe("https://example.com"); + osc.Uri.ShouldBe("https://example.com"); }); } @@ -155,7 +155,7 @@ public void Osc_Sequence_2() .And(osc => { osc.Id.ShouldBe("123"); - osc.Url.ShouldBe("https://example.com"); + osc.Uri.ShouldBe("https://example.com"); }); } @@ -172,7 +172,7 @@ public void Osc_Sequence_3() .And(osc => { osc.Id.ShouldBeNull(); - osc.Url.ShouldBe("https://example.com"); + osc.Uri.ShouldBe("https://example.com"); }); } @@ -189,7 +189,7 @@ public void Osc_Sequence_4() .And(osc => { osc.Id.ShouldBeNull(); - osc.Url.ShouldBe("https://example.com"); + osc.Uri.ShouldBe("https://example.com"); }); } @@ -206,7 +206,7 @@ public void Osc_Sequence_5() .And(osc => { osc.Id.ShouldBe("foo"); - osc.Url.ShouldBe("https://example.com"); + osc.Uri.ShouldBe("https://example.com"); }); } diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index fa3c45d05..a43db115d 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -1,14 +1,13 @@ namespace Spectre.Console.Ansi; /// -/// A ANSI/VT input parser based on the VT500-series. +/// An ANSI/VT input parser based on the VT500-series. /// public sealed class AnsiParser { private readonly Action _callback; - private readonly List _collect = []; + private readonly List _intermediates = []; private readonly List _parameters = [0]; - private readonly List _parameterSeparators = [true]; private readonly StringBuilder _parametersRaw = new(); private readonly OscParser _oscParser; private bool _hasParameter; @@ -42,15 +41,12 @@ public void Next(char code) var command = _oscParser.End(code); if (command != null) { - _callback(new AnsiToken.Osc(command)); + _callback(new AnsiToken.Osc(Command: command)); } break; case AnsiParserState.DcsPassthrough: _callback(new AnsiToken.DcsUnhook()); break; - case AnsiParserState.SosPmApcString: - _callback(new AnsiToken.ApcEnd()); - break; } } @@ -62,13 +58,13 @@ public void Next(char code) // Do nothing break; case AnsiTransitionAction.Print: - _callback(new AnsiToken.Print(code)); + _callback(new AnsiToken.Print(Character: code)); break; case AnsiTransitionAction.Execute: - _callback(new AnsiToken.Execute(code)); + _callback(new AnsiToken.Execute(Function: code)); break; case AnsiTransitionAction.Collect: - _collect.Add(code); + _intermediates.Add(code); break; case AnsiTransitionAction.Param: _parametersRaw.Append(code); @@ -76,7 +72,6 @@ public void Next(char code) if (code is ';' or ':') { _parameters.Add(0); - _parameterSeparators.Add(code is ';'); } else { @@ -90,12 +85,12 @@ public void Next(char code) break; case AnsiTransitionAction.EscDispatch: _callback(new AnsiToken.Esc( - Collect: [.. _collect], + Intermediates: [.. _intermediates], Final: code)); break; case AnsiTransitionAction.CsiDispatch: _callback(new AnsiToken.Csi( - Collect: [.. _collect], + Intermediates: [.. _intermediates], Params: _hasParameter ? [.. _parameters] : [], Final: code, ParamsRaw: _parametersRaw.ToString())); @@ -104,13 +99,8 @@ public void Next(char code) _oscParser.Next(code); break; case AnsiTransitionAction.DscPut: - _callback(new AnsiToken.DcsPut(code)); - break; - case AnsiTransitionAction.ApcPut: - _callback(new AnsiToken.ApcPut(code)); + _callback(new AnsiToken.DcsPut(Code: code)); break; - default: - throw new ArgumentOutOfRangeException(); } // Perform exit event @@ -128,14 +118,11 @@ public void Next(char code) break; case AnsiParserState.DcsPassthrough: _callback(new AnsiToken.DcsHook( - Collect: [.. _collect], + Intermediates: [.. _intermediates], Params: _hasParameter ? [.. _parameters] : [], Final: code, ParamsRaw: _parametersRaw.ToString())); break; - case AnsiParserState.SosPmApcString: - _callback(new AnsiToken.ApcStart()); - break; } } @@ -148,7 +135,7 @@ private void Clear() _parametersRaw.Clear(); _parameters.Clear(); _parameters.Add(0); - _collect.Clear(); + _intermediates.Clear(); } } @@ -182,7 +169,6 @@ internal enum AnsiTransitionAction CsiDispatch, OscPut, DscPut, - ApcPut, } internal readonly record struct AnsiTransition( @@ -294,13 +280,13 @@ private AnsiTransitionTable() // SosPmApcString { - Add(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.ApcPut); + Add(0x19, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, AnsiTransitionAction.Ignore); Add(0x00..0x17, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.ApcPut); + AnsiTransitionAction.Ignore); Add(0x1C..0x1F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.ApcPut); + AnsiTransitionAction.Ignore); Add(0x20..0x7F, AnsiParserState.SosPmApcString, AnsiParserState.SosPmApcString, - AnsiTransitionAction.ApcPut); + AnsiTransitionAction.Ignore); } // DcsEntry diff --git a/src/Spectre.Console.Ansi/AnsiToken.cs b/src/Spectre.Console.Ansi/AnsiToken.cs index 0ca1242c4..a21284c2c 100644 --- a/src/Spectre.Console.Ansi/AnsiToken.cs +++ b/src/Spectre.Console.Ansi/AnsiToken.cs @@ -8,30 +8,30 @@ public abstract record AnsiToken /// /// Prints a (Unicode codepoint) character to the screen. /// - /// - public record Print(char Code) : AnsiToken; + /// The character to print. + public record Print(char Character) : AnsiToken; /// /// Executes the specified function. /// - /// - public record Execute(char Code) : AnsiToken; + /// The C0/C1 function to execute. + public record Execute(char Function) : AnsiToken; /// /// Execute an ESC command. /// - /// - /// - public record Esc(List Collect, char Final) : AnsiToken; + /// Intermediate bytes. + /// The final byte identifying the ESC command. + public record Esc(List Intermediates, char Final) : AnsiToken; /// /// Executes a CSI command. /// - /// - /// - /// - /// - public record Csi(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + /// Intermediate bytes. + /// The parameters. + /// The final byte identifying the CSI command. + /// The raw parameters. + public record Csi(List Intermediates, List Params, char Final, string ParamsRaw) : AnsiToken; /// /// Executes an OSC command. @@ -42,36 +42,20 @@ public record Osc(OscCommand Command) : AnsiToken; /// /// Sets up a DCS handler. /// - /// - /// - /// - /// - public record DcsHook(List Collect, List Params, char Final, string ParamsRaw) : AnsiToken; + /// Intermediate bytes. + /// The parameters. + /// The final byte identifying the DCS command. + /// The raw parameters. + public record DcsHook(List Intermediates, List Params, char Final, string ParamsRaw) : AnsiToken; /// - /// Passes a device control string (DCS) to the selected handler. + /// 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; - - /// - /// Begins an APC sequence. - /// - public record ApcStart : AnsiToken; - - /// - /// Appends the specified code to the current APC sequence. - /// - /// - public record ApcPut(char Code) : AnsiToken; - - /// - /// Ends an APC sequence. - /// - public record ApcEnd() : AnsiToken; + public record DcsUnhook : AnsiToken; } \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Osc/OscCommand.cs index f53a93a5d..751ba060a 100644 --- a/src/Spectre.Console.Ansi/Osc/OscCommand.cs +++ b/src/Spectre.Console.Ansi/Osc/OscCommand.cs @@ -1,7 +1,19 @@ namespace Spectre.Console.Ansi; +/// +/// Represents an OSC command. +/// public record OscCommand { - public record HyperLinkStart(string? Id, string Url) : 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; } \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs index cc374caa1..eafc2c4cb 100644 --- a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs @@ -33,7 +33,7 @@ internal static class OscHyperLinkParser ? parameters.Trim() : parameters[..semicolonIndex].Trim(); - if (key.SequenceEqual("id")) + if (key is "id") { id = value.IsEmpty ? null : value.ToString(); } @@ -51,8 +51,8 @@ internal static class OscHyperLinkParser return new OscCommand.HyperLinkEnd(); } - return uri.Length == 0 - ? null - : new OscCommand.HyperLinkStart(id, uri.ToString()); + 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/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs index 2c83ac609..c8fc7660f 100644 --- a/src/Spectre.Console.Ansi/Osc/OscParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscParser.cs @@ -1,30 +1,30 @@ namespace Spectre.Console.Ansi; -internal enum OscParserState -{ - Invalid = 0, - Start = 1, - Osc8 = 2, -} - internal sealed class OscParser { private char[] _buffer = new char[2048]; private int _index = 0; private bool _writeToBuffer = false; - private OscParserState _state = OscParserState.Start; + private State _state = State.Start; + + private enum State + { + Invalid = 0, + Start = 1, + Osc8 = 2, + } public void Reset() { _buffer = new char[2048]; _index = 0; _writeToBuffer = false; - _state = OscParserState.Start; + _state = State.Start; } public void Next(char code) { - if (_state == OscParserState.Invalid) + if (_state == State.Invalid) { return; } @@ -38,28 +38,28 @@ public void Next(char code) switch (_state) { - case OscParserState.Invalid: + case State.Invalid: break; - case OscParserState.Start: + case State.Start: switch (code) { case '8': - _state = OscParserState.Osc8; + _state = State.Osc8; break; default: - _state = OscParserState.Invalid; + _state = State.Invalid; break; } break; - case OscParserState.Osc8: + case State.Osc8: switch (code) { case ';': _writeToBuffer = true; break; default: - _state = OscParserState.Invalid; + _state = State.Invalid; break; } @@ -69,7 +69,7 @@ public void Next(char code) public OscCommand? End(char terminator) { - if (_state == OscParserState.Osc8) + if (_state == State.Osc8) { return OscHyperLinkParser.Parse(_buffer.AsSpan(0, _index)); } From 5ca9701c5bf8008ee777e097fad824ae53bc8567 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 22 Mar 2026 23:46:46 +0100 Subject: [PATCH 11/27] Shouldly fix --- src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs index bd6f9c53a..0a7371417 100644 --- a/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs +++ b/src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs @@ -8,7 +8,6 @@ namespace Spectre.Console.Ansi.Tests; public static class ShouldlyExtensions { /// - /// 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. /// From 61bf6bff6c9fc5d007ad0f524a2a31d709be6865 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 23 Mar 2026 00:02:40 +0100 Subject: [PATCH 12/27] Convenience method for parsing ANSI strings --- .../Fixtures/AnsiParserFixture.cs | 5 +---- src/Spectre.Console.Ansi/AnsiParser.cs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs b/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs index 7a4d2f40f..f849c6a41 100644 --- a/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs +++ b/src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs @@ -7,10 +7,7 @@ public static List Parse(string text) var result = new List(); var parser = new AnsiParser(token => result.Add(token)); - foreach (var character in text) - { - parser.Next(character); - } + parser.Next(text); return result; } diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index a43db115d..6435ea82d 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -24,6 +24,18 @@ public AnsiParser(Action callback) _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. /// @@ -43,6 +55,7 @@ public void Next(char code) { _callback(new AnsiToken.Osc(Command: command)); } + break; case AnsiParserState.DcsPassthrough: _callback(new AnsiToken.DcsUnhook()); From 3807c010764bfd9aed3038967af9943aa7622441 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 23 Mar 2026 00:02:51 +0100 Subject: [PATCH 13/27] Parse unknown OSC commands --- .../AnsiParserTests.cs | 16 +++++++++ src/Spectre.Console.Ansi/Osc/OscCommand.cs | 6 ++++ src/Spectre.Console.Ansi/Osc/OscParser.cs | 35 +++++++++++++++---- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index d5ff9ad8c..e8d211e4e 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -252,4 +252,20 @@ public void Osc_Sequence_8() .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"); + }); + } } \ No newline at end of file diff --git a/src/Spectre.Console.Ansi/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Osc/OscCommand.cs index 751ba060a..01b292766 100644 --- a/src/Spectre.Console.Ansi/Osc/OscCommand.cs +++ b/src/Spectre.Console.Ansi/Osc/OscCommand.cs @@ -16,4 +16,10 @@ 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/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs index c8fc7660f..a5f869ad9 100644 --- a/src/Spectre.Console.Ansi/Osc/OscParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscParser.cs @@ -2,7 +2,11 @@ namespace Spectre.Console.Ansi; internal sealed class OscParser { - private char[] _buffer = new char[2048]; + private const int MaxBufferSize = 2048; + + private char[] _buffer = new char[MaxBufferSize]; + private int _bufferIndex = 0; + private char[] _everything = new char[MaxBufferSize]; private int _index = 0; private bool _writeToBuffer = false; private State _state = State.Start; @@ -16,7 +20,9 @@ private enum State public void Reset() { - _buffer = new char[2048]; + _buffer = new char[MaxBufferSize]; + _everything = new char[MaxBufferSize]; + _bufferIndex = 0; _index = 0; _writeToBuffer = false; _state = State.Start; @@ -24,6 +30,14 @@ public void Reset() public void Next(char code) { + // Keep track of everything so we can submit unknown OSC commands. + // Not pretty, but it solves the problem. We should perhaps rethink this parser. + if (_index < MaxBufferSize) + { + _everything[_index] = code; + _index++; + } + if (_state == State.Invalid) { return; @@ -31,8 +45,12 @@ public void Next(char code) if (_writeToBuffer) { - _buffer[_index] = code; - _index++; + if (_bufferIndex < MaxBufferSize) + { + _buffer[_bufferIndex] = code; + _bufferIndex++; + } + return; } @@ -69,9 +87,14 @@ public void Next(char code) public OscCommand? End(char terminator) { - if (_state == State.Osc8) + if (_state == State.Osc8 && _bufferIndex > 0) + { + return OscHyperLinkParser.Parse(_buffer.AsSpan(0, _bufferIndex)); + } + + if (_state == State.Invalid && _index > 0) { - return OscHyperLinkParser.Parse(_buffer.AsSpan(0, _index)); + return new OscCommand.Unknown(Data: new string(_everything, 0, _index)); } return null; From fcaf00ead355a141fd7929a904bda849f2f372ab Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:05:57 +0200 Subject: [PATCH 14/27] Handle Unicode characters properly --- .../AnsiParserTests.cs | 96 +++++++++++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 62 +++++++++++- src/Spectre.Console.Ansi/AnsiToken.cs | 24 ++++- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index e8d211e4e..354c6bdce 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -268,4 +268,100 @@ public void Osc_Sequence_9() osc.Data.ShouldBe("123;;lol"); }); } + + [Fact(DisplayName = "print: accented latin")] + public void Print_AccentedLatin() + { + // 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_BoxDrawing() + { + // 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_AstralBetweenAscii() + { + // 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_ResumesAfterCsi() + { + // 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/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 6435ea82d..b38bb8796 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -5,12 +5,15 @@ namespace Spectre.Console.Ansi; /// public sealed class AnsiParser { + private const int ReplacementCodepoint = 0xFFFD; + 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; /// @@ -42,6 +45,14 @@ public void Next(string text) /// 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 entry event @@ -71,7 +82,7 @@ public void Next(char code) // Do nothing break; case AnsiTransitionAction.Print: - _callback(new AnsiToken.Print(Character: code)); + EmitPrint(code); break; case AnsiTransitionAction.Execute: _callback(new AnsiToken.Execute(Function: code)); @@ -150,6 +161,34 @@ private void 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)); + } } internal enum AnsiParserState @@ -470,9 +509,30 @@ public AnsiTransition GetTransition( 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) diff --git a/src/Spectre.Console.Ansi/AnsiToken.cs b/src/Spectre.Console.Ansi/AnsiToken.cs index a21284c2c..2b91b9949 100644 --- a/src/Spectre.Console.Ansi/AnsiToken.cs +++ b/src/Spectre.Console.Ansi/AnsiToken.cs @@ -6,10 +6,28 @@ namespace Spectre.Console.Ansi; public abstract record AnsiToken { /// - /// Prints a (Unicode codepoint) character to the screen. + /// Prints a Unicode scalar value (codepoint) to the screen. /// - /// The character to print. - public record Print(char Character) : AnsiToken; + /// + /// 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. From 396353372e5f72b202bc46908a510118981ba92c Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:13:18 +0200 Subject: [PATCH 15/27] Minor fixes --- src/Spectre.Console.Ansi/AnsiParser.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index b38bb8796..b9df8b80b 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -55,7 +55,7 @@ public void Next(char code) var (nextState, action) = AnsiTransitionTable.Shared.GetTransition(_currentState, code); - // Perform entry event + // Perform the exit action of the current state if (_currentState != nextState) { switch (_currentState) @@ -74,7 +74,7 @@ public void Next(char code) } } - // Perform the action + // Perform the transition action switch (action) { case AnsiTransitionAction.None: @@ -127,7 +127,7 @@ public void Next(char code) break; } - // Perform exit event + // Perform the entry action of the next state if (_currentState != nextState) { switch (nextState) @@ -225,11 +225,7 @@ internal enum AnsiTransitionAction internal readonly record struct AnsiTransition( AnsiParserState State, - AnsiTransitionAction Action) -{ - public AnsiParserState State { get; } = State; - public AnsiTransitionAction Action { get; } = Action; -} + AnsiTransitionAction Action); internal sealed class AnsiTransitionTable { From d308880b4e32e46a0f1dab6cc7d38c9cf692d652 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:19:20 +0200 Subject: [PATCH 16/27] Handle BEL as OSC terminator --- .../AnsiParserTests.cs | 42 +++++++++++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 3 ++ 2 files changed, 45 insertions(+) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 354c6bdce..0430af30c 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -269,6 +269,48 @@ public void Osc_Sequence_9() }); } + [Fact(DisplayName = "osc 0: terminated by BEL")] + public void Osc_TerminatedByBel() + { + // 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_HyperlinkTerminatedByBel() + { + // 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_BelReturnsToGround() + { + // 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 = "print: accented latin")] public void Print_AccentedLatin() { diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index b9df8b80b..64b5eeddc 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -493,6 +493,9 @@ private AnsiTransitionTable() 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); } } From f0e1cffcf802843ec28401b6e6b92c9303d39826 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:31:20 +0200 Subject: [PATCH 17/27] Reuse OSC parser buffers --- .../AnsiParserTests.cs | 30 ++++++++++++++----- src/Spectre.Console.Ansi/Osc/OscParser.cs | 8 ++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 0430af30c..285861c59 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -270,7 +270,7 @@ public void Osc_Sequence_9() } [Fact(DisplayName = "osc 0: terminated by BEL")] - public void Osc_TerminatedByBel() + public void Osc_Terminated_By_BEL() { // Given, When var result = AnsiParserFixture.Parse("\e]0;title\a"); // OSC 0 window title, BEL terminator @@ -283,7 +283,7 @@ public void Osc_TerminatedByBel() } [Fact(DisplayName = "osc 8: Hyperlink terminated by BEL")] - public void Osc_HyperlinkTerminatedByBel() + public void Osc_Hyperlink_Terminated_By_BEL() { // Given, When var result = AnsiParserFixture.Parse("\e]8;;https://example.com\a"); @@ -300,7 +300,7 @@ public void Osc_HyperlinkTerminatedByBel() } [Fact(DisplayName = "osc: BEL terminator returns to ground")] - public void Osc_BelReturnsToGround() + public void Osc_BEL_Returns_To_Ground() { // Given, When var result = AnsiParserFixture.Parse("\e]0;t\aX"); // OSC ... BEL, then a printable @@ -311,8 +311,24 @@ public void Osc_BelReturnsToGround() 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 = "print: accented latin")] - public void Print_AccentedLatin() + public void Print_Accented_Latin() { // Given, When var result = AnsiParserFixture.Parse("aä"); // "aä" @@ -324,7 +340,7 @@ public void Print_AccentedLatin() } [Fact(DisplayName = "print: box-drawing characters")] - public void Print_BoxDrawing() + public void Print_Box_Drawing() { // Given, When var result = AnsiParserFixture.Parse("─│┌"); // "─│┌" @@ -359,7 +375,7 @@ public void Print_Astral() } [Fact(DisplayName = "print: astral codepoint between ascii")] - public void Print_AstralBetweenAscii() + public void Print_Astral_Between_Ascii() { // Given, When var result = AnsiParserFixture.Parse("a\U0001F600b"); @@ -372,7 +388,7 @@ public void Print_AstralBetweenAscii() } [Fact(DisplayName = "print: non-ascii resumes after CSI")] - public void Print_ResumesAfterCsi() + public void Print_Resumes_After_Csi() { // Given, When var result = AnsiParserFixture.Parse("\e[0m─"); // SGR reset, then "─" diff --git a/src/Spectre.Console.Ansi/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs index a5f869ad9..7efa95142 100644 --- a/src/Spectre.Console.Ansi/Osc/OscParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscParser.cs @@ -4,9 +4,9 @@ internal sealed class OscParser { private const int MaxBufferSize = 2048; - private char[] _buffer = new char[MaxBufferSize]; + private readonly char[] _buffer = new char[MaxBufferSize]; private int _bufferIndex = 0; - private char[] _everything = new char[MaxBufferSize]; + private readonly char[] _everything = new char[MaxBufferSize]; private int _index = 0; private bool _writeToBuffer = false; private State _state = State.Start; @@ -20,8 +20,8 @@ private enum State public void Reset() { - _buffer = new char[MaxBufferSize]; - _everything = new char[MaxBufferSize]; + // Reuse the buffers instead of reallocating. Every read is bounded by the + // indices below, so stale data from a previous sequence is never observed. _bufferIndex = 0; _index = 0; _writeToBuffer = false; From 518edfb27fed66f52fd7e07fed5c6da26eee694a Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:39:35 +0200 Subject: [PATCH 18/27] Count empty CSI parameters --- .../AnsiParserTests.cs | 39 +++++++++++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 6 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 285861c59..e9254cc70 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -125,6 +125,45 @@ public void Csi_Sequence_5() }); } + [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 = "osc 8: Hyperlink")] public void Osc_Sequence_1() { diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 64b5eeddc..e3565cb33 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -93,6 +93,11 @@ public void Next(char code) 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); @@ -103,7 +108,6 @@ public void Next(char code) var accumulator = (_parameters[^1] * 10) + code - 48; _parameters[^1] = accumulator > (int.MaxValue / 10) - 10 ? 0 : accumulator; - _hasParameter = true; } break; From 9cac8b525719c67a9fd57d72151d034155e0b7d4 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 01:59:17 +0200 Subject: [PATCH 19/27] Parse OSC 8 hyperlinks per spec --- .../AnsiParserTests.cs | 36 ++++++++++++++++++- .../Osc/OscHyperLinkParser.cs | 36 ++++++++----------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index e9254cc70..82eeb1d75 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -236,7 +236,7 @@ public void Osc_Sequence_4() public void Osc_Sequence_5() { // Given, When - var result = AnsiParserFixture.Parse("\e]8;=value;id=foo;https://example.com\e\\"); + var result = AnsiParserFixture.Parse("\e]8;=value:id=foo;https://example.com\e\\"); // Then result.Count.ShouldBe(2); @@ -308,6 +308,40 @@ public void Osc_Sequence_9() }); } + [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() { diff --git a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs index eafc2c4cb..cbca57b6f 100644 --- a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs @@ -6,44 +6,36 @@ internal static class OscHyperLinkParser { string? id = null; - // Find the final ';' which separates params from the URI. - var lastSemicolon = buffer.LastIndexOf(';'); - if (lastSemicolon < 0) + // 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[..lastSemicolon]; - var uri = buffer[(lastSemicolon + 1)..]; + var parameters = buffer[..separator]; + var uri = buffer[(separator + 1)..]; - // Parse key=value pairs from the params section. + // Params are a ':'-separated list of key=value pairs. while (!parameters.IsEmpty) { - var equalsIndex = parameters.IndexOf('='); - if (equalsIndex < 0) - { - break; - } - - var key = parameters[..equalsIndex].Trim(); - parameters = parameters[(equalsIndex + 1)..]; - - var semicolonIndex = parameters.IndexOf(';'); - var value = semicolonIndex < 0 - ? parameters.Trim() - : parameters[..semicolonIndex].Trim(); + var colonIndex = parameters.IndexOf(':'); + var pair = colonIndex < 0 ? parameters : parameters[..colonIndex]; - if (key is "id") + 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 (semicolonIndex < 0) + if (colonIndex < 0) { break; } - parameters = parameters[(semicolonIndex + 1)..]; + parameters = parameters[(colonIndex + 1)..]; } if (id == null && uri.Length == 0) From bd13d969c2b24c27633bd6a1c5a59c9ff0e01812 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 02:00:19 +0200 Subject: [PATCH 20/27] Saturate CSI parameter overflow --- .../AnsiParserTests.cs | 17 +++++++++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 82eeb1d75..9ae436ebb 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -164,6 +164,23 @@ public void Csi_Multiple_Empty_Params() }); } + [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 = "osc 8: Hyperlink")] public void Osc_Sequence_1() { diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index e3565cb33..db6885d10 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -6,6 +6,7 @@ namespace Spectre.Console.Ansi; public sealed class AnsiParser { private const int ReplacementCodepoint = 0xFFFD; + private const int MaxParameterValue = 65535; private readonly Action _callback; private readonly List _intermediates = []; @@ -106,8 +107,8 @@ public void Next(char code) { Debug.Assert(char.IsDigit(code), "Expected digit"); - var accumulator = (_parameters[^1] * 10) + code - 48; - _parameters[^1] = accumulator > (int.MaxValue / 10) - 10 ? 0 : accumulator; + var accumulator = (_parameters[^1] * 10L) + (code - 48); + _parameters[^1] = accumulator > MaxParameterValue ? MaxParameterValue : (int)accumulator; } break; From cb694e31cb30710e04f008dda1d191dce8badf41 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 02:10:54 +0200 Subject: [PATCH 21/27] Give tokens value equality --- .../AnsiParserTests.cs | 35 ++++++ src/Spectre.Console.Ansi/AnsiToken.cs | 108 +++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index 9ae436ebb..a77080780 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -181,6 +181,41 @@ public void Csi_Parameter_Overflow_Saturates() }); } + [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 = "osc 8: Hyperlink")] public void Osc_Sequence_1() { diff --git a/src/Spectre.Console.Ansi/AnsiToken.cs b/src/Spectre.Console.Ansi/AnsiToken.cs index 2b91b9949..974b66c15 100644 --- a/src/Spectre.Console.Ansi/AnsiToken.cs +++ b/src/Spectre.Console.Ansi/AnsiToken.cs @@ -40,7 +40,33 @@ public record Execute(char Function) : AnsiToken; /// /// Intermediate bytes. /// The final byte identifying the ESC command. - public record Esc(List Intermediates, char Final) : AnsiToken; + 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. @@ -49,7 +75,45 @@ public record Esc(List Intermediates, char Final) : AnsiToken; /// The parameters. /// The final byte identifying the CSI command. /// The raw parameters. - public record Csi(List Intermediates, List Params, char Final, string ParamsRaw) : AnsiToken; + 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. @@ -64,7 +128,45 @@ public record Osc(OscCommand Command) : AnsiToken; /// The parameters. /// The final byte identifying the DCS command. /// The raw parameters. - public record DcsHook(List Intermediates, List Params, char Final, string ParamsRaw) : AnsiToken; + 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. From e65ed190df5093ed5d85358605a6c221ec318099 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 02:17:22 +0200 Subject: [PATCH 22/27] Clarify parser XML docs --- src/Spectre.Console.Ansi/AnsiParser.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index db6885d10..0394a4c95 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -3,6 +3,11 @@ 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; @@ -18,7 +23,7 @@ public sealed class AnsiParser private AnsiParserState _currentState; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The callback to be used for parsed tokens. public AnsiParser(Action callback) From 2d34690e2b1d6953fba9e45321ff8485d0becaa8 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 02:22:47 +0200 Subject: [PATCH 23/27] Abort OSC on CAN and SUB --- .../AnsiParserTests.cs | 11 +++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index a77080780..a92c6a4b5 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -452,6 +452,17 @@ public void Osc_Reuses_Buffers_Without_Leaking() .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() { diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 0394a4c95..ecd5cf824 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -67,10 +67,15 @@ public void Next(char code) switch (_currentState) { case AnsiParserState.OscString: - var command = _oscParser.End(code); - if (command != null) + // CAN and SUB abort the string; only a normal terminator (ST/BEL) + // dispatches the accumulated command. + if (!IsAbort(code)) { - _callback(new AnsiToken.Osc(Command: command)); + var command = _oscParser.End(code); + if (command != null) + { + _callback(new AnsiToken.Osc(Command: command)); + } } break; @@ -199,6 +204,13 @@ private void EmitPrint(char code) _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'; + } } internal enum AnsiParserState From 2bc5db24f61c09d5b51506ec914f2d3d01e5f408 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 11:37:13 +0200 Subject: [PATCH 24/27] Add Reset and Flush to the parser --- .../AnsiParserTests.cs | 51 +++++++++++++++++++ src/Spectre.Console.Ansi/AnsiParser.cs | 27 ++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs index a92c6a4b5..d99a8805c 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs @@ -216,6 +216,57 @@ public void Token_Esc_Value_Equality() 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() { diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index ecd5cf824..8b4843ed7 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -168,6 +168,33 @@ public void Next(char code) _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; From 90ca6cd70ae0e05fa41fccd813a887bf2b9344e1 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 11:37:45 +0200 Subject: [PATCH 25/27] Document OSC buffer limit --- src/Spectre.Console.Ansi/Osc/OscParser.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Spectre.Console.Ansi/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs index 7efa95142..db95cd968 100644 --- a/src/Spectre.Console.Ansi/Osc/OscParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscParser.cs @@ -2,6 +2,8 @@ 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]; From a364bbf3e3c3a53818d0e646b93d6e8647185151 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 11:43:23 +0200 Subject: [PATCH 26/27] Use a single OSC buffer --- src/Spectre.Console.Ansi/AnsiParser.cs | 2 +- src/Spectre.Console.Ansi/Osc/OscCommand.cs | 2 +- .../Osc/OscHyperLinkParser.cs | 2 +- src/Spectre.Console.Ansi/Osc/OscParser.cs | 88 ++++++------------- 4 files changed, 31 insertions(+), 63 deletions(-) diff --git a/src/Spectre.Console.Ansi/AnsiParser.cs b/src/Spectre.Console.Ansi/AnsiParser.cs index 8b4843ed7..14dda4cc3 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/AnsiParser.cs @@ -71,7 +71,7 @@ public void Next(char code) // dispatches the accumulated command. if (!IsAbort(code)) { - var command = _oscParser.End(code); + var command = _oscParser.End(); if (command != null) { _callback(new AnsiToken.Osc(Command: command)); diff --git a/src/Spectre.Console.Ansi/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Osc/OscCommand.cs index 01b292766..bd5d901bb 100644 --- a/src/Spectre.Console.Ansi/Osc/OscCommand.cs +++ b/src/Spectre.Console.Ansi/Osc/OscCommand.cs @@ -3,7 +3,7 @@ namespace Spectre.Console.Ansi; /// /// Represents an OSC command. /// -public record OscCommand +public abstract record OscCommand { /// /// Represents a hyperlink start. diff --git a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs index cbca57b6f..70545c1c1 100644 --- a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs @@ -7,7 +7,7 @@ internal static class OscHyperLinkParser 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 ';'. + // section precedes it, and the URI itself may legally contain ';' var separator = buffer.IndexOf(';'); if (separator < 0) { diff --git a/src/Spectre.Console.Ansi/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Osc/OscParser.cs index db95cd968..6ce6d7ffe 100644 --- a/src/Spectre.Console.Ansi/Osc/OscParser.cs +++ b/src/Spectre.Console.Ansi/Osc/OscParser.cs @@ -7,10 +7,8 @@ internal sealed class OscParser private const int MaxBufferSize = 2048; private readonly char[] _buffer = new char[MaxBufferSize]; - private int _bufferIndex = 0; - private readonly char[] _everything = new char[MaxBufferSize]; - private int _index = 0; - private bool _writeToBuffer = false; + private int _length; + private int _payloadStart; private State _state = State.Start; private enum State @@ -18,87 +16,57 @@ private enum State Invalid = 0, Start = 1, Osc8 = 2, + HyperLink = 3, } public void Reset() { - // Reuse the buffers instead of reallocating. Every read is bounded by the - // indices below, so stale data from a previous sequence is never observed. - _bufferIndex = 0; - _index = 0; - _writeToBuffer = false; + // 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) { - // Keep track of everything so we can submit unknown OSC commands. - // Not pretty, but it solves the problem. We should perhaps rethink this parser. - if (_index < MaxBufferSize) + // Accumulate the whole string so an unknown OSC command can be reported in full. + if (_length < MaxBufferSize) { - _everything[_index] = code; - _index++; - } - - if (_state == State.Invalid) - { - return; - } - - if (_writeToBuffer) - { - if (_bufferIndex < MaxBufferSize) - { - _buffer[_bufferIndex] = code; - _bufferIndex++; - } - - return; + _buffer[_length] = code; + _length++; } switch (_state) { - case State.Invalid: - break; case State.Start: - switch (code) - { - case '8': - _state = State.Osc8; - break; - default: - _state = State.Invalid; - break; - } - + _state = code == '8' ? State.Osc8 : State.Invalid; break; case State.Osc8: - switch (code) + if (code == ';') { - case ';': - _writeToBuffer = true; - break; - default: - _state = State.Invalid; - break; + // 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(char terminator) + public OscCommand? End() { - if (_state == State.Osc8 && _bufferIndex > 0) - { - return OscHyperLinkParser.Parse(_buffer.AsSpan(0, _bufferIndex)); - } - - if (_state == State.Invalid && _index > 0) + switch (_state) { - return new OscCommand.Unknown(Data: new string(_everything, 0, _index)); + 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; } - - return null; } -} \ No newline at end of file +} From 81def27be7ea768db82ee98a4a37176c67ab50f1 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 19 Jul 2026 11:56:29 +0200 Subject: [PATCH 27/27] Reorganize parser into a Parsing folder --- .../Parsing/AnsiParser.cs | 241 +++++++++++++++ .../Parsing/AnsiParserState.cs | 19 ++ .../{ => Parsing}/AnsiToken.cs | 0 .../Parsing/AnsiTransition.cs | 5 + .../Parsing/AnsiTransitionAction.cs | 15 + .../AnsiTransitionTable.cs} | 276 ------------------ .../{ => Parsing}/Osc/OscCommand.cs | 0 .../{ => Parsing}/Osc/OscHyperLinkParser.cs | 0 .../{ => Parsing}/Osc/OscParser.cs | 0 9 files changed, 280 insertions(+), 276 deletions(-) create mode 100644 src/Spectre.Console.Ansi/Parsing/AnsiParser.cs create mode 100644 src/Spectre.Console.Ansi/Parsing/AnsiParserState.cs rename src/Spectre.Console.Ansi/{ => Parsing}/AnsiToken.cs (100%) create mode 100644 src/Spectre.Console.Ansi/Parsing/AnsiTransition.cs create mode 100644 src/Spectre.Console.Ansi/Parsing/AnsiTransitionAction.cs rename src/Spectre.Console.Ansi/{AnsiParser.cs => Parsing/AnsiTransitionTable.cs} (66%) rename src/Spectre.Console.Ansi/{ => Parsing}/Osc/OscCommand.cs (100%) rename src/Spectre.Console.Ansi/{ => Parsing}/Osc/OscHyperLinkParser.cs (100%) rename src/Spectre.Console.Ansi/{ => Parsing}/Osc/OscParser.cs (100%) 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/AnsiToken.cs b/src/Spectre.Console.Ansi/Parsing/AnsiToken.cs similarity index 100% rename from src/Spectre.Console.Ansi/AnsiToken.cs rename to src/Spectre.Console.Ansi/Parsing/AnsiToken.cs 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/AnsiParser.cs b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs similarity index 66% rename from src/Spectre.Console.Ansi/AnsiParser.cs rename to src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs index 14dda4cc3..6fa2dc43a 100644 --- a/src/Spectre.Console.Ansi/AnsiParser.cs +++ b/src/Spectre.Console.Ansi/Parsing/AnsiTransitionTable.cs @@ -1,281 +1,5 @@ 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'; - } -} - -internal enum AnsiParserState -{ - Ground = 0, - Escape, - EscapeIntermediate, - CsiEntry, - CsiIntermediate, - CsiParam, - CsiIgnore, - DcsEntry, - DcsParam, - DcsIntermediate, - DcsPassthrough, - DcsIgnore, - OscString, - SosPmApcString, -} - -internal enum AnsiTransitionAction -{ - None = 0, - Ignore, - Print, - Execute, - Collect, - Param, - EscDispatch, - CsiDispatch, - OscPut, - DscPut, -} - -internal readonly record struct AnsiTransition( - AnsiParserState State, - AnsiTransitionAction Action); - internal sealed class AnsiTransitionTable { private readonly Dictionary> _transitions; diff --git a/src/Spectre.Console.Ansi/Osc/OscCommand.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs similarity index 100% rename from src/Spectre.Console.Ansi/Osc/OscCommand.cs rename to src/Spectre.Console.Ansi/Parsing/Osc/OscCommand.cs diff --git a/src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs similarity index 100% rename from src/Spectre.Console.Ansi/Osc/OscHyperLinkParser.cs rename to src/Spectre.Console.Ansi/Parsing/Osc/OscHyperLinkParser.cs diff --git a/src/Spectre.Console.Ansi/Osc/OscParser.cs b/src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs similarity index 100% rename from src/Spectre.Console.Ansi/Osc/OscParser.cs rename to src/Spectre.Console.Ansi/Parsing/Osc/OscParser.cs