From da03a2643803cff9c239ab54c1953be6b861138c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:23:58 +0000 Subject: [PATCH 1/2] Keep a CRLF split across two reads as one line break LineOutputHandler normalized line endings per chunk, so a chunk ending in CR followed by one starting with LF became two line breaks and raised a spurious empty line. Recognize line endings on the buffered text instead, and hold a trailing CR back until the next chunk shows whether an LF follows. Fixes #85 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CGpRxGxuvhstHo5zXQoWSA --- RunCommand.Test/LineOutputHandlerTests.cs | 65 +++++++++++++++++++++++ RunCommand/LineOutputHandler.cs | 43 ++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/RunCommand.Test/LineOutputHandlerTests.cs b/RunCommand.Test/LineOutputHandlerTests.cs index 9c59d59..cbe1df4 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 + CollectionAssert.AreEqual(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 + CollectionAssert.AreEqual(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.AreEqual(0, lines.Count); + 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 + CollectionAssert.AreEqual(ExpectedEveryLineEndingKind, lines); + Assert.AreEqual("e", handler.outputBuffer); + } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index 86afb3b..2de1788 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -58,17 +58,46 @@ 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; + for (int i = 0; i < buffer.Length; i++) { - 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]); + if (buffer[i + 1] == '\n') + { + i++; + } + + lineStart = i + 1; + } + else if (IsLineBreak(c)) + { + onLineReceived?.Invoke(buffer[lineStart..i]); + lineStart = i + 1; + } } + + 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'; } From 64f982f9231c33dbf412359ec2437c7e2df9d006 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:31:53 +0000 Subject: [PATCH 2/2] Advance the line scanner without changing a for loop's counter in its body Also use the MSTest assertions the analyzer suggests in the new tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CGpRxGxuvhstHo5zXQoWSA --- RunCommand.Test/LineOutputHandlerTests.cs | 8 ++++---- RunCommand/LineOutputHandler.cs | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/RunCommand.Test/LineOutputHandlerTests.cs b/RunCommand.Test/LineOutputHandlerTests.cs index cbe1df4..46f2e94 100644 --- a/RunCommand.Test/LineOutputHandlerTests.cs +++ b/RunCommand.Test/LineOutputHandlerTests.cs @@ -110,7 +110,7 @@ public void HandleStandardOutputDataShouldTreatCrlfSplitAcrossChunksAsOneLineBre handler.HandleStandardOutputData("\ny\n"); // Assert - CollectionAssert.AreEqual(ExpectedXY, lines); + Assert.AreSequenceEqual(ExpectedXY, lines); Assert.AreEqual(string.Empty, handler.outputBuffer); } @@ -126,7 +126,7 @@ public void HandleStandardErrorDataShouldTreatCrlfSplitAcrossChunksAsOneLineBrea handler.HandleStandardErrorData("\ny\n"); // Assert - CollectionAssert.AreEqual(ExpectedXY, lines); + Assert.AreSequenceEqual(ExpectedXY, lines); Assert.AreEqual(string.Empty, handler.errorBuffer); } @@ -141,7 +141,7 @@ public void HandleStandardOutputDataShouldHoldBackTrailingCarriageReturn() handler.HandleStandardOutputData("x\r"); // Assert - Assert.AreEqual(0, lines.Count); + Assert.IsEmpty(lines); Assert.AreEqual("x\r", handler.outputBuffer); } @@ -156,7 +156,7 @@ public void HandleStandardOutputDataShouldSplitOnEveryLineEndingKind() handler.HandleStandardOutputData("a\r\nb\rc\nd\r\n\r\ne"); // Assert - CollectionAssert.AreEqual(ExpectedEveryLineEndingKind, lines); + Assert.AreSequenceEqual(ExpectedEveryLineEndingKind, lines); Assert.AreEqual("e", handler.outputBuffer); } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index 2de1788..c8044ac 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -66,7 +66,8 @@ private static void ProcessDataByLine(string data, ref string buffer, Action