Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
namespace ktsu.Semantics.Strings;

using System;
using System.Linq;

/// <summary>
/// Validates that a string has exactly the specified number of lines
/// </summary>
/// <remarks>
/// 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. <c>\r\n</c> is one break, and so
/// is a lone <c>\r</c>, <c>\n</c>, U+2028 or U+2029, matching <see cref="IsMultiLineAttribute"/>.
/// Empty strings are considered to have 0 lines.
/// A string with no line breaks has 1 line.
/// </remarks>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
namespace ktsu.Semantics.Strings;

using System;
using System.Linq;

/// <summary>
/// Validates that a string has at most the specified maximum number of lines
/// </summary>
/// <remarks>
/// 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. <c>\r\n</c> is one break, and so
/// is a lone <c>\r</c>, <c>\n</c>, U+2028 or U+2029, matching <see cref="IsMultiLineAttribute"/>.
/// Empty strings are considered to have 0 lines.
/// A string with no line breaks has 1 line.
/// </remarks>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
namespace ktsu.Semantics.Strings;

using System;
using System.Linq;

/// <summary>
/// Validates that a string has at least the specified minimum number of lines
/// </summary>
/// <remarks>
/// 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. <c>\r\n</c> is one break, and so
/// is a lone <c>\r</c>, <c>\n</c>, U+2028 or U+2029, matching <see cref="IsMultiLineAttribute"/>.
/// Empty strings are considered to have 0 lines.
/// A string with no line breaks has 1 line.
/// </remarks>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions Semantics.Strings/Validation/LineBreaks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Strings;

using System.Globalization;

/// <summary>
/// The one definition of a line break that every line-related validation attribute shares, so that
/// <see cref="IsSingleLineAttribute"/>, <see cref="IsMultiLineAttribute"/> and the <c>Has*Lines</c>
/// attributes cannot disagree about how many lines a string has.
/// </summary>
internal static class LineBreaks
{
/// <summary>
/// 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).
/// </summary>
/// <param name="c">The character to check.</param>
/// <returns><see langword="true"/> if <paramref name="c"/> is a line break.</returns>
internal static bool IsLineBreak(char c) =>
c is '\n' or '\r'
|| char.GetUnicodeCategory(c) is UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator;

/// <summary>
/// Counts the lines in a non-empty string: one more than the number of line breaks, where
/// <c>\r\n</c> is a single break and a lone <c>\r</c>, <c>\n</c>, U+2028 or U+2029 is one each.
/// </summary>
/// <param name="value">The string to count, which must not be empty.</param>
/// <returns>The number of lines in <paramref name="value"/>.</returns>
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;
}
}
51 changes: 51 additions & 0 deletions Semantics.Test/LineCountValidatorsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ private sealed partial record Min2 : SemanticString<Min2> { }
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")]
private sealed partial record Max2 : SemanticString<Max2> { }

[HasExactLines(3)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")]
private sealed partial record Exact3 : SemanticString<Exact3> { }

[HasMaximumLines(1)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")]
private sealed partial record Max1 : SemanticString<Max1> { }

[IsMultiLine]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")]
private sealed partial record MultiLine : SemanticString<MultiLine> { }

[IsSingleLine]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used via generic type references")]
private sealed partial record SingleLine : SemanticString<SingleLine> { }

[TestMethod]
public void HasExactLines_ZeroAndTwo_WithLfAndCrlf()
{
Expand Down Expand Up @@ -61,4 +77,39 @@ public void HasMaximumLines_Two_WithLfAndCrlf()
Max2 twoCrlf = SemanticString<Max2>.Create<Max2>("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<Exact2>.Create<Exact2>(twoLines).WeakString);
Assert.AreEqual(twoLines, SemanticString<Min2>.Create<Min2>(twoLines).WeakString);
Assert.AreEqual(twoLines, SemanticString<Max2>.Create<Max2>(twoLines).WeakString);
Assert.ThrowsExactly<ArgumentException>(() => SemanticString<Max1>.Create<Max1>(twoLines));
}

[TestMethod]
public void ConsecutiveCrlfPairsAreSeparateBreaks()
{
Exact3 three = SemanticString<Exact3>.Create<Exact3>("a\r\n\r\nb");
Assert.AreEqual("a\r\n\r\nb", three.WeakString);
Assert.ThrowsExactly<ArgumentException>(() => SemanticString<Max2>.Create<Max2>("a\r\n\r\nb"));
Assert.ThrowsExactly<ArgumentException>(() => SemanticString<Exact3>.Create<Exact3>("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<MultiLine>.Create<MultiLine>(twoLines).WeakString);
Assert.ThrowsExactly<ArgumentException>(() => SemanticString<SingleLine>.Create<SingleLine>(twoLines));
}
}
Loading