From f8459d1b2e5dfa1fa5cf7f00fd3a7c613d5866e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:28:19 +0000 Subject: [PATCH 1/2] Normalize line endings in one pass, so Mac keeps every break NormalizeLineEndings(Mac) first turned each CRLF into CR, which left an LF that followed a CRLF directly after the new CR. The LF pass skips an LF preceded by CR, so "a\r\n\nb" came out as "a\r\nb": a line break was lost and the Mac output held a Windows ending. Every style now replaces \r\n, \r or \n in a single regex pass, so no intermediate string can form a new sequence. The results for inputs that did not hit the bug are unchanged. Fixes #137 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LasidbyRZfyxgW8Z9wQu9M --- Extensions.Test/StringExtensionsTests.cs | 28 ++++++++++++++++++++++++ Extensions/StringExtensions.cs | 14 +++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index e5d6c95..fbc7678 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -304,6 +304,34 @@ public void NormalizeLineEndingsToNone() Assert.AreEqual(expected, result); } + // A CRLF followed by an LF, and a CR followed by a CRLF, are two line breaks each. Normalizing in + // several passes used to merge the first into a new CRLF under Mac, losing a break and leaving a + // Windows ending in Mac output. + + [TestMethod] + [DataRow("a\r\n\nb", LineEndingStyle.Unix, "a\n\nb")] + [DataRow("a\r\n\nb", LineEndingStyle.Windows, "a\r\n\r\nb")] + [DataRow("a\r\n\nb", LineEndingStyle.Mac, "a\r\rb")] + [DataRow("a\r\n\nb", LineEndingStyle.None, "ab")] + [DataRow("a\r\n\nb", LineEndingStyle.Mixed, "a\n\nb")] + [DataRow("a\r\r\nb", LineEndingStyle.Unix, "a\n\nb")] + [DataRow("a\r\r\nb", LineEndingStyle.Windows, "a\r\n\r\nb")] + [DataRow("a\r\r\nb", LineEndingStyle.Mac, "a\r\rb")] + [DataRow("a\r\r\nb", LineEndingStyle.None, "ab")] + [DataRow("a\r\r\nb", LineEndingStyle.Mixed, "a\n\nb")] + public void NormalizeLineEndingsKeepsEveryBreakOfAdjacentMixedEndings(string input, LineEndingStyle style, string expected) => + Assert.AreEqual(expected, input.NormalizeLineEndings(style)); + + [TestMethod] + [DataRow(LineEndingStyle.Unix)] + [DataRow(LineEndingStyle.Windows)] + [DataRow(LineEndingStyle.Mac)] + public void NormalizeLineEndingsOfAdjacentMixedEndingsIsDetectedAsTheTargetStyle(LineEndingStyle style) + { + Assert.AreEqual(style, "a\r\n\nb".NormalizeLineEndings(style).DetermineLineEndings()); + Assert.AreEqual(style, "a\r\r\nb".NormalizeLineEndings(style).DetermineLineEndings()); + } + [TestMethod] public void NormalizeLineEndingsToMixed() { diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index 417919d..9298534 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -126,6 +126,10 @@ public static string ReplaceOrdinal(this string s, string oldValue, string newVa private static Regex LineEndingRegexWindows { get; } = new(@"\r\n", RegexOptions.Compiled); private static Regex LineEndingRegexMac { get; } = new(@"\r(?!\n)", RegexOptions.Compiled); + // One pass over every kind of line ending. Normalizing in several passes lets an earlier pass + // build a sequence the next one reads differently: turning "\r\n\n" into "\r\n" by way of "\r\n". + private static Regex AnyLineEndingRegex { get; } = new(@"\r\n|\r|\n", RegexOptions.Compiled); + /// /// Determines the line ending style of the specified string. /// @@ -202,11 +206,11 @@ public static string NormalizeLineEndings(this string s, LineEndingStyle style) return style switch { - LineEndingStyle.None => LineEndingRegexUnix.Replace(LineEndingRegexWindows.Replace(LineEndingRegexMac.Replace(s, ""), ""), ""), - LineEndingStyle.Unix => LineEndingRegexWindows.Replace(LineEndingRegexMac.Replace(s, "\n"), "\n"), - LineEndingStyle.Windows => LineEndingRegexUnix.Replace(LineEndingRegexMac.Replace(s, "\r\n"), "\r\n"), - LineEndingStyle.Mac => LineEndingRegexUnix.Replace(LineEndingRegexWindows.Replace(s, "\r"), "\r"), - LineEndingStyle.Mixed => LineEndingRegexWindows.Replace(LineEndingRegexMac.Replace(s, "\n"), "\n"), + LineEndingStyle.None => AnyLineEndingRegex.Replace(s, ""), + LineEndingStyle.Unix => AnyLineEndingRegex.Replace(s, "\n"), + LineEndingStyle.Windows => AnyLineEndingRegex.Replace(s, "\r\n"), + LineEndingStyle.Mac => AnyLineEndingRegex.Replace(s, "\r"), + LineEndingStyle.Mixed => AnyLineEndingRegex.Replace(s, "\n"), _ => throw new NotImplementedException("Unknown line ending style."), }; } From fdb954c67632bc8007bea1576904527a1a6b724e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:41:13 +0000 Subject: [PATCH 2/2] Give the single-pass line ending regex a match timeout SonarCloud flags a Regex built without a timeout (S6444), which failed the quality gate on its security rating. The pattern is a plain alternation that cannot backtrack, so one second is far more than any match needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LasidbyRZfyxgW8Z9wQu9M --- Extensions/StringExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index 9298534..f1ea5b2 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -127,8 +127,8 @@ public static string ReplaceOrdinal(this string s, string oldValue, string newVa private static Regex LineEndingRegexMac { get; } = new(@"\r(?!\n)", RegexOptions.Compiled); // One pass over every kind of line ending. Normalizing in several passes lets an earlier pass - // build a sequence the next one reads differently: turning "\r\n\n" into "\r\n" by way of "\r\n". - private static Regex AnyLineEndingRegex { get; } = new(@"\r\n|\r|\n", RegexOptions.Compiled); + // build a sequence the next one reads differently: under Mac, "\r\n\n" became "\r" + "\n", a new CRLF. + private static Regex AnyLineEndingRegex { get; } = new(@"\r\n|\r|\n", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); /// /// Determines the line ending style of the specified string.