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
20 changes: 20 additions & 0 deletions Extensions.Test/StringExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,26 @@
CollectionAssert.AreEqual(new List<string> { "foobar-", "bazqux" }, result);
}

[TestMethod]
[DataRow("self-explanatory", 4f, new[] { "self", "-exp", "lana", "tory" })]
[DataRow("abcde-fghij", 5f, new[] { "abcde", "-fghi", "j" })]
[DataRow("well-known e-mail", 4f, new[] { "well", "-kno", "wn", "e-", "mail" })]
public void NominalWordWrapNeverLeavesALineOfOnlyHyphens(string input, float maxChars, string[] expected)
{
// A hard break that cuts only the trailing hyphen off a chunk keeps the next chunk of the word on the hyphen's line (#182).
List<string> result = [.. input.NominalWordWrap(maxChars, 1f)];
CollectionAssert.AreEqual(expected, result);

Check warning on line 589 in Extensions.Test/StringExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaGbPY6pmtHvmxH3RgPp&open=AaGbPY6pmtHvmxH3RgPp&pullRequest=183
Assert.IsFalse(result.Any(line => line.Length > 0 && line.Trim('-').Length == 0), "No line should hold only hyphens.");

Check warning on line 590 in Extensions.Test/StringExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.DoesNotContain' instead of 'Assert.IsFalse'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaGbPY6pmtHvmxH3RgPr&open=AaGbPY6pmtHvmxH3RgPr&pullRequest=183
Assert.IsFalse(result.Any(line => line.Length > maxChars), "No line should be wider than the width.");

Check warning on line 591 in Extensions.Test/StringExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.DoesNotContain' instead of 'Assert.IsFalse'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaGbPY6pmtHvmxH3RgPs&open=AaGbPY6pmtHvmxH3RgPs&pullRequest=183
}

[TestMethod]
public void NominalWordWrapAtWidthOneKeepsTheHyphenOnItsOwnLine()
{
List<string> result = [.. "x-ray".NominalWordWrap(1f, 1f)];
CollectionAssert.AreEqual(new List<string> { "x", "-", "r", "a", "y" }, result);

Check warning on line 598 in Extensions.Test/StringExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaGbPY6pmtHvmxH3RgPq&open=AaGbPY6pmtHvmxH3RgPq&pullRequest=183
}

[TestMethod]
public void NominalWordWrapDoesNotBreakOnUnderscores()
{
Expand Down
56 changes: 45 additions & 11 deletions Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@
#endif
}

private static Regex LineEndingRegexUnix { get; } = new(@"(?<!\r)\n", RegexOptions.Compiled);

Check warning on line 125 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.
private static Regex LineEndingRegexWindows { get; } = new(@"\r\n", RegexOptions.Compiled);

Check warning on line 126 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.
private static Regex LineEndingRegexMac { get; } = new(@"\r(?!\n)", RegexOptions.Compiled);

Check warning on line 127 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 127 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

// One pass over every kind of line ending. Normalizing in several passes lets an earlier pass
// build a sequence the next one reads differently: under Mac, "\r\n\n" became "\r" + "\n", a new CRLF.
Expand Down Expand Up @@ -271,7 +271,7 @@
/// </summary>
private const char SoftHyphen = '­';

private static IEnumerable<string> NominalWordWrapIterator(string text, int maxCharsPerLine)

Check warning on line 274 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 46 to the 15 allowed.

Check warning on line 274 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 46 to the 15 allowed.
{
string normalized = text.NormalizeLineEndings(LineEndingStyle.Unix);
string[] segments = normalized.Split('\n');
Expand All @@ -292,10 +292,24 @@
// The atoms on the current line after its first, with the line length before each (and its separator), so a
// break that would not leave room for a rendered hyphen can fall back to an earlier boundary.
List<(int Index, int StartLength)> lineAtoms = [];

// Whether the current line is only the hyphen run a hard break cut off the end of a chunk ("self-" at width 4
// leaves "-"). The boundary after it is not taken, so the line never ends up holding nothing but hyphens.
bool lineIsHyphenRemainder = false;
for (int i = 0, next; i < atoms.Count; i = next)
{
next = i + 1;
(string atomText, bool glueSpace, bool hyphenBefore, bool canBreak) = atoms[i];
bool joinRemainder = lineIsHyphenRemainder && !glueSpace;
lineIsHyphenRemainder = false;
if (joinRemainder)
{
// The next chunk of the same word continues on the hyphen's line, and is hard-broken from there as usual.
atomText = line + atomText;
line.Clear();
lineAtoms.Clear();
}

bool atLineStart = line.Length == 0;
int separatorLength = (!atLineStart && glueSpace) ? 1 : 0;
int projectedLength = line.Length + separatorLength + atomText.Length;
Expand Down Expand Up @@ -332,7 +346,7 @@
yield return line.ToString();
line.Clear();
lineAtoms.Clear();
atLineStart = true;

Check warning on line 349 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this useless assignment to local variable 'atLineStart'.

Check warning on line 349 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this useless assignment to local variable 'atLineStart'.
}
else if (!atLineStart)
{
Expand All @@ -347,21 +361,14 @@
}

// At the start of a fresh line: place the atom, hard-breaking it if it still cannot fit.
string remaining = atomText;
while (remaining.Length > maxCharsPerLine)
List<string> fullLines = HardBreak(atomText, maxCharsPerLine, out string remaining);
foreach (string fullLine in fullLines)
{
int cut = maxCharsPerLine;
if (char.IsHighSurrogate(remaining[cut - 1]) && char.IsLowSurrogate(remaining[cut]))
{
// Never split a surrogate pair. At a width of one, let the pair overhang instead.
cut = cut > 1 ? cut - 1 : 2;
}

yield return remaining.Substring(0, cut);
remaining = remaining.Substring(cut);
yield return fullLine;
}

line.Append(remaining);
lineIsHyphenRemainder = fullLines.Count > 0 && remaining.Length > 0 && remaining.Trim('-').Length == 0;
}

if (line.Length > 0)
Expand All @@ -371,6 +378,33 @@
}
}

/// <summary>
/// Cuts text that is wider than a line into full lines, never splitting a surrogate pair.
/// </summary>
/// <param name="text">The text to place at the start of a line.</param>
/// <param name="maxCharsPerLine">The line width in characters.</param>
/// <param name="remaining">The text left over for the last, partly filled line.</param>
/// <returns>The full lines cut from the start of the text, in order.</returns>
private static List<string> HardBreak(string text, int maxCharsPerLine, out string remaining)
{
List<string> fullLines = [];
remaining = text;
while (remaining.Length > maxCharsPerLine)
{
int cut = maxCharsPerLine;
if (char.IsHighSurrogate(remaining[cut - 1]) && char.IsLowSurrogate(remaining[cut]))
{
// Never split a surrogate pair. At a width of one, let the pair overhang instead.
cut = cut > 1 ? cut - 1 : 2;
}

fullLines.Add(remaining.Substring(0, cut));
remaining = remaining.Substring(cut);
}

return fullLines;
}

private static List<(string Text, bool GlueSpace, bool HyphenBefore, bool CanBreak)> BuildAtoms(string segment)
{
List<(string Text, bool GlueSpace, bool HyphenBefore, bool CanBreak)> atoms = [];
Expand Down Expand Up @@ -428,7 +462,7 @@
return words;
}

private static List<(string Text, bool HyphenBefore)> ChunkWord(string word)

Check warning on line 465 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 465 in Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
// Split a whitespace-delimited word into break-units at hyphenation opportunities:
// after a visible hyphen (kept on the left chunk) or at a soft hyphen (removed unless a break lands there).
Expand Down
Loading