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));
+ }
}