using System.Diagnostics;
using System.Text.RegularExpressions;
using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Processes;
public readonly record struct ProcessOutput
{
///
/// Parsed text with escape sequences and line endings removed
///
public required string Text { get; init; }
///
/// Optional Raw output,
/// mainly for debug and logging.
///
public string? RawText { get; init; }
///
/// True if output from stderr, false for stdout.
///
public bool IsStdErr { get; init; }
///
/// Count of newlines to append to the output.
/// (Currently not used)
///
public int NewLines { get; init; }
///
/// Instruction to clear last n lines
/// From carriage return '\r'
///
public int CarriageReturn { get; init; }
///
/// Instruction to move write cursor up n lines
/// From Ansi sequence ESC[#A where # is count of lines
///
public int CursorUp { get; init; }
///
/// Flag-type Ansi commands
///
public AnsiCommand AnsiCommand { get; init; }
///
/// Apc message sent from the subprocess
///
public ApcMessage? ApcMessage { get; init; }
public static ProcessOutput FromStdOutLine(string text)
{
return FromLine(text, false);
}
public static ProcessOutput FromStdErrLine(string text)
{
return FromLine(text, true);
}
private static ProcessOutput FromLine(string text, bool isStdErr)
{
// Parse APC message
if (ApcParser.TryParse(text, out var message))
{
// Override and return
return new ProcessOutput
{
RawText = text,
Text = text,
IsStdErr = isStdErr,
ApcMessage = message
};
}
// Normal parsing
var originalText = text;
// Remove \r from the beginning of the line, and add them to count
var clearLines = 0;
// Strip leading carriage return until newline
while (!text.StartsWith(Environment.NewLine) && text.StartsWith('\r'))
{
text = text[1..];
clearLines++;
}
// Detect ansi escape sequences
var ansiCommands = AnsiCommand.None;
var cursorUp = 0;
if (text.Contains('\u001b'))
{
// Cursor up sequence - ESC[#A
// Where # is count of lines to move up, if not specified, default to 1
if (Regex.Match(text, @"\x1B\[(\d+)?A") is {Success: true} match)
{
// Default to 1 if no count
cursorUp = int.TryParse(match.Groups[1].Value, out var n) ? n : 1;
// Remove the sequence from the text
text = text[..match.Index] + text[(match.Index + match.Length)..];
}
// Erase line sequence - ESC[#K
// (For erasing we don't move the cursor)
// Omitted - defaults to 0
// 0 - clear from cursor to end of line
// 1 - clear from start of line to cursor
// 2 - clear entire line
if (Regex.Match(text, @"\x1B\[(0|1|2)?K") is {Success: true} match2)
{
// Default to 0 if no count
var eraseLineMode = int.TryParse(match2.Groups[1].Value, out var n) ? n : 0;
ansiCommands |= eraseLineMode switch
{
0 => AnsiCommand.EraseToEndOfLine,
1 => AnsiCommand.EraseFromStartOfLine,
2 => AnsiCommand.EraseLine,
_ => AnsiCommand.None
};
// Remove the sequence from the text
text = text[..match2.Index] + text[(match2.Index + match2.Length)..];
}
// Private modes, all of these can be safely ignored
if (Regex.Match(text, @"\x1B\[?(25l|25h|47l|47h|1049h|1049l)") is
{Success: true} match3)
{
// Remove the sequence from the text
text = text[..match3.Index] + text[(match3.Index + match3.Length)..];
}
}
// If text still contains escape sequences, remove them
if (text.Contains('\u001b'))
{
Debug.WriteLine($"Removing unhandled escape sequences: {text.ToRepr()}");
text = AnsiParser.AnsiEscapeSequenceRegex().Replace(text, "");
}
return new ProcessOutput
{
RawText = originalText,
Text = text,
IsStdErr = isStdErr,
CarriageReturn = clearLines,
CursorUp = cursorUp,
AnsiCommand = ansiCommands,
};
}
}