diff --git a/RunCommand.Test/LineOutputHandlerTests.cs b/RunCommand.Test/LineOutputHandlerTests.cs index 9c59d59..46f2e94 100644 --- a/RunCommand.Test/LineOutputHandlerTests.cs +++ b/RunCommand.Test/LineOutputHandlerTests.cs @@ -5,6 +5,9 @@ namespace ktsu.RunCommand.Test; [TestClass] public class LineOutputHandlerTests { + private static readonly string[] ExpectedXY = ["x", "y"]; + private static readonly string[] ExpectedEveryLineEndingKind = ["a", "b", "c", "d", ""]; + [TestMethod] public void HandleStandardOutputDataShouldProcessLinesCorrectly() { @@ -94,4 +97,66 @@ public void HandleStandardErrorDataShouldBufferIncompleteLines() Assert.AreEqual(expectedLines.Length, index); Assert.AreEqual("Incomplete", handler.errorBuffer); } + + [TestMethod] + public void HandleStandardOutputDataShouldTreatCrlfSplitAcrossChunksAsOneLineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + + // Act + handler.HandleStandardOutputData("x\r"); + handler.HandleStandardOutputData("\ny\n"); + + // Assert + Assert.AreSequenceEqual(ExpectedXY, lines); + Assert.AreEqual(string.Empty, handler.outputBuffer); + } + + [TestMethod] + public void HandleStandardErrorDataShouldTreatCrlfSplitAcrossChunksAsOneLineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardError: lines.Add); + + // Act + handler.HandleStandardErrorData("x\r"); + handler.HandleStandardErrorData("\ny\n"); + + // Assert + Assert.AreSequenceEqual(ExpectedXY, lines); + Assert.AreEqual(string.Empty, handler.errorBuffer); + } + + [TestMethod] + public void HandleStandardOutputDataShouldHoldBackTrailingCarriageReturn() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + + // Act + handler.HandleStandardOutputData("x\r"); + + // Assert + Assert.IsEmpty(lines); + Assert.AreEqual("x\r", handler.outputBuffer); + } + + [TestMethod] + public void HandleStandardOutputDataShouldSplitOnEveryLineEndingKind() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + + // Act + handler.HandleStandardOutputData("a\r\nb\rc\nd\r\n\r\ne"); + + // Assert + Assert.AreSequenceEqual(ExpectedEveryLineEndingKind, lines); + Assert.AreEqual("e", handler.outputBuffer); + } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index 86afb3b..c8044ac 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -58,17 +58,48 @@ internal override void HandleStandardErrorData(string data) /// The data to be processed. /// The buffer to store incomplete lines. /// The action to be invoked for each complete line received. + /// + /// Line endings are recognised on the buffered text rather than on each chunk, so a CRLF split across two + /// reads is still one line break. A trailing CR stays in the buffer until the next chunk shows whether an LF follows. + /// private static void ProcessDataByLine(string data, ref string buffer, Action? onLineReceived) { - buffer += data.ReplaceLineEndings(); - while (buffer.Contains(Environment.NewLine)) + buffer += data; + int lineStart = 0; + int i = 0; + while (i < buffer.Length) { - string[] split = buffer.Split(Environment.NewLine, 2); - buffer = split.Length == 1 - ? string.Empty - : split[1]; + char c = buffer[i]; + if (c == '\r') + { + if (i == buffer.Length - 1) + { + break; + } - onLineReceived?.Invoke(split[0]); + onLineReceived?.Invoke(buffer[lineStart..i]); + i += buffer[i + 1] == '\n' ? 2 : 1; + lineStart = i; + } + else if (IsLineBreak(c)) + { + onLineReceived?.Invoke(buffer[lineStart..i]); + i++; + lineStart = i; + } + else + { + i++; + } } + + buffer = buffer[lineStart..]; } + + /// + /// Determines whether a character other than CR ends a line, matching the set string.ReplaceLineEndings recognises. + /// + /// The character to test. + /// if is LF, NEL, LS, PS or FF; otherwise . + private static bool IsLineBreak(char c) => c is '\n' or '\u0085' or '\u2028' or '\u2029' or '\f'; }