Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e31e5df
wip: translation table
patriksvensson Feb 1, 2026
d65bbd9
wip: initial parsing
patriksvensson Feb 2, 2026
71b393c
parsing: add raw parameters
patriksvensson Feb 3, 2026
ff0388c
Minor changes
patriksvensson Mar 21, 2026
a9a3832
Do explicit entry/exit. Add OSC parser
patriksvensson Mar 21, 2026
b1760ae
More tests
patriksvensson Mar 22, 2026
5ec6c45
More tests
patriksvensson Mar 22, 2026
f58468c
Add Ghostty
patriksvensson Mar 22, 2026
e228821
Clean up
patriksvensson Mar 22, 2026
be7fd0b
Remove APC. Fix some things
patriksvensson Mar 22, 2026
5ca9701
Shouldly fix
patriksvensson Mar 22, 2026
61bf6bf
Convenience method for parsing ANSI strings
patriksvensson Mar 22, 2026
3807c01
Parse unknown OSC commands
patriksvensson Mar 22, 2026
fcaf00e
Handle Unicode characters properly
patriksvensson Jul 18, 2026
3963533
Minor fixes
patriksvensson Jul 18, 2026
d308880
Handle BEL as OSC terminator
patriksvensson Jul 18, 2026
f0e1cff
Reuse OSC parser buffers
patriksvensson Jul 18, 2026
518edfb
Count empty CSI parameters
patriksvensson Jul 18, 2026
9cac8b5
Parse OSC 8 hyperlinks per spec
patriksvensson Jul 18, 2026
bd13d96
Saturate CSI parameter overflow
patriksvensson Jul 19, 2026
cb694e3
Give tokens value equality
patriksvensson Jul 19, 2026
e65ed19
Clarify parser XML docs
patriksvensson Jul 19, 2026
2d34690
Abort OSC on CAN and SUB
patriksvensson Jul 19, 2026
2bc5db2
Add Reset and Flush to the parser
patriksvensson Jul 19, 2026
90ca6cd
Document OSC buffer limit
patriksvensson Jul 19, 2026
a364bbf
Use a single OSC buffer
patriksvensson Jul 19, 2026
81def27
Reorganize parser into a Parsing folder
patriksvensson Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
612 changes: 612 additions & 0 deletions src/Spectre.Console.Ansi.Tests/AnsiParserTests.cs

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/Spectre.Console.Ansi.Tests/Fixtures/AnsiParserFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Spectre.Console.Ansi.Tests;

public sealed class AnsiParserFixture
{
public static List<AnsiToken> Parse(string text)
{
var result = new List<AnsiToken>();
var parser = new AnsiParser(token => result.Add(token));

parser.Next(text);

return result;
}
}
41 changes: 41 additions & 0 deletions src/Spectre.Console.Ansi.Tests/Utilities/ShouldlyExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Diagnostics;

namespace Spectre.Console.Ansi.Tests;

/// <summary>
/// Provides extensions for testing using the Shouldly-style fluent assertions.
/// </summary>
public static class ShouldlyExtensions
{
/// <summary>
/// Useful for fluent testing patterns where additional assertions or operations
/// are chained together in a readable manner.
/// </summary>
/// <typeparam name="T">The type of the object.</typeparam>
/// <param name="item">The object to operate on.</param>
/// <returns>The original object, to allow further chaining.</returns>
[DebuggerStepThrough]
public static T And<T>(this T item)
{
return item;
}

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The type of the object.</typeparam>
/// <param name="item">The object to operate on.</param>
/// <param name="action">An action to perform on the object.</param>
/// <returns>The original object, to allow further chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="action"/> is null.</exception>
[DebuggerStepThrough]
public static T And<T>(this T item, Action<T> action)
{
ArgumentNullException.ThrowIfNull(action);

action(item);
return item;
}
}
3 changes: 2 additions & 1 deletion src/Spectre.Console.Ansi/AnsiDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
241 changes: 241 additions & 0 deletions src/Spectre.Console.Ansi/Parsing/AnsiParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
namespace Spectre.Console.Ansi;

/// <summary>
/// An ANSI/VT input parser based on the VT500-series.
/// </summary>
/// <remarks>
/// Instances are stateful and not thread-safe: a single parser must not be used from
/// multiple threads, and the callback must not re-enter <see cref="Next(char)"/> or
/// <see cref="Next(string)"/> on the same instance.
/// </remarks>
public sealed class AnsiParser
{
private const int ReplacementCodepoint = 0xFFFD;
private const int MaxParameterValue = 65535;

private readonly Action<AnsiToken> _callback;
private readonly List<char> _intermediates = [];
private readonly List<int> _parameters = [0];
private readonly StringBuilder _parametersRaw = new();
private readonly OscParser _oscParser;
private bool _hasParameter;
private char _highSurrogate;
private AnsiParserState _currentState;

/// <summary>
/// Initializes a new instance of the <see cref="AnsiParser"/> class.
/// </summary>
/// <param name="callback">The callback to be used for parsed tokens.</param>
public AnsiParser(Action<AnsiToken> callback)
{
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
_currentState = AnsiParserState.Ground;
_oscParser = new OscParser();
}

/// <summary>
/// Processes the specified text.
/// </summary>
/// <param name="text">The text to process.</param>
public void Next(string text)
{
foreach (var character in text)
{
Next(character);
}
}

/// <summary>
/// Processes the specified code.
/// </summary>
/// <param name="code">The code to process.</param>
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;
}

/// <summary>
/// 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.
/// </summary>
public void Flush()
{
if (_highSurrogate != '\0')
{
_highSurrogate = '\0';
_callback(new AnsiToken.Print(ReplacementCodepoint));
}
}

/// <summary>
/// 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.
/// </summary>
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';
}
}
19 changes: 19 additions & 0 deletions src/Spectre.Console.Ansi/Parsing/AnsiParserState.cs
Original file line number Diff line number Diff line change
@@ -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,
}
Loading