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..cf504fb9 --- /dev/null +++ b/Semantics.Strings/Validation/LineBreaks.cs @@ -0,0 +1,45 @@ +// 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++) + { + // 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) + { + 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)); + } }