From 92737ca50ca82faf3e4e7827e8e665317891e21a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:30:47 +0000 Subject: [PATCH 1/2] [patch] Count every line break in HasMinimumLines, HasMaximumLines and HasExactLines The three Has*Lines attributes counted only '\n', so a lone '\r', U+2028 or U+2029 added no line, while IsMultiLine and IsSingleLine treated '\r' and U+2028 as breaks. "a\rb" was multi-line yet had one line, so [HasMaximumLines(1)] accepted strings [IsSingleLine] rejected. The CRLF block meant to stop double counting computed the same number it started with. A shared LineBreaks helper now defines a break once: '\n', '\r', U+2028 and U+2029, with "\r\n" counting as one. All five line attributes use it, and the no-op CRLF block is gone. Fixes ktsu-dev/Semantics#299 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FtvW1bPVVfCTF7fCWpeyRE --- .../Format/HasExactLinesAttribute.cs | 15 ++---- .../Format/HasMaximumLinesAttribute.cs | 15 ++---- .../Format/HasMinimumLinesAttribute.cs | 15 ++---- .../Attributes/Format/IsMultiLineAttribute.cs | 2 +- .../Format/IsSingleLineAttribute.cs | 2 +- Semantics.Strings/Validation/LineBreaks.cs | 50 ++++++++++++++++++ Semantics.Test/LineCountValidatorsTests.cs | 51 +++++++++++++++++++ 7 files changed, 112 insertions(+), 38 deletions(-) create mode 100644 Semantics.Strings/Validation/LineBreaks.cs diff --git a/Semantics.Strings/Validation/Attributes/Format/HasExactLinesAttribute.cs b/Semantics.Strings/Validation/Attributes/Format/HasExactLinesAttribute.cs index 3c6d8a2a..5a50cdf5 100644 --- a/Semantics.Strings/Validation/Attributes/Format/HasExactLinesAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Format/HasExactLinesAttribute.cs @@ -3,13 +3,13 @@ namespace ktsu.Semantics.Strings; using System; -using System.Linq; /// /// Validates that a string has exactly the specified number of lines /// /// -/// Line count is determined by counting line break characters plus one for the final line. +/// Line count is the number of line breaks plus one for the final line. \r\n is one break, and so +/// is a lone \r, \n, U+2028 or U+2029, matching . /// Empty strings are considered to have 0 lines. /// A string with no line breaks has 1 line. /// @@ -56,16 +56,7 @@ protected override ValidationResult ValidateValue(string value) : ValidationResult.Failure($"The text must have exactly {exactLines} line(s)."); } - // Count line breaks and add 1 - int lineCount = value.Count(c => c == '\n') + 1; - - // Handle Windows-style line endings (\r\n) - don't double count - if (value.Contains("\r\n")) - { - int crlfCount = value.Split(["\r\n"], StringSplitOptions.None).Length - 1; - int lfOnlyCount = value.Count(c => c == '\n') - crlfCount; - lineCount = crlfCount + lfOnlyCount + 1; - } + int lineCount = LineBreaks.CountLines(value); bool hasExactLines = lineCount == exactLines; return hasExactLines diff --git a/Semantics.Strings/Validation/Attributes/Format/HasMaximumLinesAttribute.cs b/Semantics.Strings/Validation/Attributes/Format/HasMaximumLinesAttribute.cs index a5475090..1dd52aaa 100644 --- a/Semantics.Strings/Validation/Attributes/Format/HasMaximumLinesAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Format/HasMaximumLinesAttribute.cs @@ -3,13 +3,13 @@ namespace ktsu.Semantics.Strings; using System; -using System.Linq; /// /// Validates that a string has at most the specified maximum number of lines /// /// -/// Line count is determined by counting line break characters plus one for the final line. +/// Line count is the number of line breaks plus one for the final line. \r\n is one break, and so +/// is a lone \r, \n, U+2028 or U+2029, matching . /// Empty strings are considered to have 0 lines. /// A string with no line breaks has 1 line. /// @@ -53,16 +53,7 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Success(); // Empty strings have 0 lines, which is <= any positive maximum } - // Count line breaks and add 1 - int lineCount = value.Count(c => c == '\n') + 1; - - // Handle Windows-style line endings (\r\n) - don't double count - if (value.Contains("\r\n")) - { - int crlfCount = value.Split(["\r\n"], StringSplitOptions.None).Length - 1; - int lfOnlyCount = value.Count(c => c == '\n') - crlfCount; - lineCount = crlfCount + lfOnlyCount + 1; - } + int lineCount = LineBreaks.CountLines(value); bool hasValidLineCount = lineCount <= maximumLines; return hasValidLineCount diff --git a/Semantics.Strings/Validation/Attributes/Format/HasMinimumLinesAttribute.cs b/Semantics.Strings/Validation/Attributes/Format/HasMinimumLinesAttribute.cs index 8aca2d81..8eca1d7c 100644 --- a/Semantics.Strings/Validation/Attributes/Format/HasMinimumLinesAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Format/HasMinimumLinesAttribute.cs @@ -3,13 +3,13 @@ namespace ktsu.Semantics.Strings; using System; -using System.Linq; /// /// Validates that a string has at least the specified minimum number of lines /// /// -/// Line count is determined by counting line break characters plus one for the final line. +/// Line count is the number of line breaks plus one for the final line. \r\n is one break, and so +/// is a lone \r, \n, U+2028 or U+2029, matching . /// Empty strings are considered to have 0 lines. /// A string with no line breaks has 1 line. /// @@ -56,16 +56,7 @@ protected override ValidationResult ValidateValue(string value) : ValidationResult.Failure($"The text must have at least {minimumLines} line(s)."); } - // Count line breaks and add 1 - int lineCount = value.Count(c => c == '\n') + 1; - - // Handle Windows-style line endings (\r\n) - don't double count - if (value.Contains("\r\n")) - { - int crlfCount = value.Split(["\r\n"], StringSplitOptions.None).Length - 1; - int lfOnlyCount = value.Count(c => c == '\n') - crlfCount; - lineCount = crlfCount + lfOnlyCount + 1; - } + int lineCount = LineBreaks.CountLines(value); bool hasValidLineCount = lineCount >= minimumLines; return hasValidLineCount diff --git a/Semantics.Strings/Validation/Attributes/Format/IsMultiLineAttribute.cs b/Semantics.Strings/Validation/Attributes/Format/IsMultiLineAttribute.cs index 090174b0..bd47d054 100644 --- a/Semantics.Strings/Validation/Attributes/Format/IsMultiLineAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Format/IsMultiLineAttribute.cs @@ -40,7 +40,7 @@ protected override ValidationResult ValidateValue(string value) } // Check for any line break characters - bool hasLineBreaks = value.Any(c => c == '\n' || c == '\r' || char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.LineSeparator); + bool hasLineBreaks = value.Any(LineBreaks.IsLineBreak); return hasLineBreaks ? ValidationResult.Success() : ValidationResult.Failure("The value must contain line breaks."); diff --git a/Semantics.Strings/Validation/Attributes/Format/IsSingleLineAttribute.cs b/Semantics.Strings/Validation/Attributes/Format/IsSingleLineAttribute.cs index 01ed96eb..89fbf1df 100644 --- a/Semantics.Strings/Validation/Attributes/Format/IsSingleLineAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Format/IsSingleLineAttribute.cs @@ -40,7 +40,7 @@ protected override ValidationResult ValidateValue(string value) } // Check for any line break characters - bool isValid = !value.Any(c => c == '\n' || c == '\r' || char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.LineSeparator); + bool isValid = !value.Any(LineBreaks.IsLineBreak); return isValid ? ValidationResult.Success() diff --git a/Semantics.Strings/Validation/LineBreaks.cs b/Semantics.Strings/Validation/LineBreaks.cs new file mode 100644 index 00000000..26976f47 --- /dev/null +++ b/Semantics.Strings/Validation/LineBreaks.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Strings; + +using System.Globalization; + +/// +/// The one definition of a line break that every line-related validation attribute shares, so that +/// , and the Has*Lines +/// attributes cannot disagree about how many lines a string has. +/// +internal static class LineBreaks +{ + /// + /// Reports whether a character ends a line: a line feed, a carriage return, the Unicode line + /// separator (U+2028) or the Unicode paragraph separator (U+2029). + /// + /// The character to check. + /// if is a line break. + internal static bool IsLineBreak(char c) => + c is '\n' or '\r' + || char.GetUnicodeCategory(c) is UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator; + + /// + /// Counts the lines in a non-empty string: one more than the number of line breaks, where + /// \r\n is a single break and a lone \r, \n, U+2028 or U+2029 is one each. + /// + /// The string to count, which must not be empty. + /// The number of lines in . + internal static int CountLines(string value) + { + int lines = 1; + for (int i = 0; i < value.Length; i++) + { + if (!IsLineBreak(value[i])) + { + continue; + } + + if (value[i] == '\r' && i + 1 < value.Length && value[i + 1] == '\n') + { + i++; + } + + lines++; + } + + return lines; + } +} diff --git a/Semantics.Test/LineCountValidatorsTests.cs b/Semantics.Test/LineCountValidatorsTests.cs index a73a6c87..47f21b72 100644 --- a/Semantics.Test/LineCountValidatorsTests.cs +++ b/Semantics.Test/LineCountValidatorsTests.cs @@ -24,6 +24,22 @@ private sealed partial record Min2 : SemanticString { } [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] private sealed partial record Max2 : SemanticString { } + [HasExactLines(3)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record Exact3 : SemanticString { } + + [HasMaximumLines(1)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record Max1 : SemanticString { } + + [IsMultiLine] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record MultiLine : SemanticString { } + + [IsSingleLine] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")] + private sealed partial record SingleLine : SemanticString { } + [TestMethod] public void HasExactLines_ZeroAndTwo_WithLfAndCrlf() { @@ -61,4 +77,39 @@ public void HasMaximumLines_Two_WithLfAndCrlf() Max2 twoCrlf = SemanticString.Create("1\r\n2"); Assert.AreEqual("1\r\n2", twoCrlf.WeakString); } + + [TestMethod] + [DataRow("a\rb")] + [DataRow("a\nb")] + [DataRow("a\r\nb")] + [DataRow("a\u2028b")] + [DataRow("a\u2029b")] + public void EveryLineEndingCountsAsOneBreak(string twoLines) + { + Assert.AreEqual(twoLines, SemanticString.Create(twoLines).WeakString); + Assert.AreEqual(twoLines, SemanticString.Create(twoLines).WeakString); + Assert.AreEqual(twoLines, SemanticString.Create(twoLines).WeakString); + Assert.ThrowsExactly(() => SemanticString.Create(twoLines)); + } + + [TestMethod] + public void ConsecutiveCrlfPairsAreSeparateBreaks() + { + Exact3 three = SemanticString.Create("a\r\n\r\nb"); + Assert.AreEqual("a\r\n\r\nb", three.WeakString); + Assert.ThrowsExactly(() => SemanticString.Create("a\r\n\r\nb")); + Assert.ThrowsExactly(() => SemanticString.Create("a\n\rb\r\nc")); + } + + [TestMethod] + [DataRow("a\rb")] + [DataRow("a\nb")] + [DataRow("a\r\nb")] + [DataRow("a\u2028b")] + [DataRow("a\u2029b")] + public void LineCountsAgreeWithIsMultiLineAndIsSingleLine(string twoLines) + { + Assert.AreEqual(twoLines, SemanticString.Create(twoLines).WeakString); + Assert.ThrowsExactly(() => SemanticString.Create(twoLines)); + } } From f27d43c5d85eea7075a2291c481efd7891330a79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:54:25 +0000 Subject: [PATCH 2/2] Count CRLF once without advancing the loop counter in CountLines SonarCloud S127: CountLines skipped the '\n' of a "\r\n" pair by incrementing the for-loop variable in its body. It now leaves the counter alone and skips a '\n' whose previous character is '\r'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FtvW1bPVVfCTF7fCWpeyRE --- Semantics.Strings/Validation/LineBreaks.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Semantics.Strings/Validation/LineBreaks.cs b/Semantics.Strings/Validation/LineBreaks.cs index 26976f47..cf504fb9 100644 --- a/Semantics.Strings/Validation/LineBreaks.cs +++ b/Semantics.Strings/Validation/LineBreaks.cs @@ -32,17 +32,12 @@ internal static int CountLines(string value) int lines = 1; for (int i = 0; i < value.Length; i++) { - if (!IsLineBreak(value[i])) + // The '\n' of a "\r\n" pair ends the same line its '\r' already counted. + bool secondHalfOfCrlf = value[i] == '\n' && i > 0 && value[i - 1] == '\r'; + if (IsLineBreak(value[i]) && !secondHalfOfCrlf) { - continue; + lines++; } - - if (value[i] == '\r' && i + 1 < value.Length && value[i + 1] == '\n') - { - i++; - } - - lines++; } return lines;