Skip to content

Commit ac9e31c

Browse files
committed
Move TextWrapper code in separate class to aid testability
1 parent 0a0fa20 commit ac9e31c

File tree

3 files changed

+348
-107
lines changed

3 files changed

+348
-107
lines changed

src/CommandLine/Text/HelpText.cs

Lines changed: 5 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ internal static void AddLine(StringBuilder builder, string value, int maximumLen
677677
value = value.TrimEnd();
678678

679679
builder.AppendWhen(builder.Length > 0, Environment.NewLine);
680-
builder.Append(WrapAndIndentText(value, 0, maximumLength));
680+
builder.Append(TextWrapper.WrapAndIndentText(value, 0, maximumLength));
681681
}
682682

683683
private IEnumerable<Specification> GetSpecificationsFromType(Type type)
@@ -806,7 +806,7 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe
806806

807807
//note that we need to indent trim the start of the string because it's going to be
808808
//appended to an existing line that is as long as the indent-level
809-
var indented = WrapAndIndentText(optionHelpText, maxLength+TotalOptionPadding, widthOfHelpText).TrimStart();
809+
var indented = TextWrapper.WrapAndIndentText(optionHelpText, maxLength+TotalOptionPadding, widthOfHelpText).TrimStart();
810810

811811
optionsHelp
812812
.Append(indented)
@@ -949,111 +949,9 @@ private static string FormatDefaultValue<T>(T value)
949949
: string.Empty;
950950
}
951951

952-
/// <summary>
953-
/// Splits a string into a words and performs wrapping while also preserving line-breaks and sub-indentation
954-
/// </summary>
955-
/// <param name="input">The string to wrap</param>
956-
/// <param name="indentLevel">The amount of padding at the start of each string</param>
957-
/// <param name="columnWidth">The number of characters we can use for text</param>
958-
/// <remarks>
959-
/// The use of "width" is slightly confusing in other methods. In this method, the columnWidth
960-
/// parameter is the number of characters we can use for text regardless of the indent level.
961-
/// For example, if columnWidth is 10 and indentLevel is 2, the input
962-
/// "a string for wrapping 01234567890123"
963-
/// would return
964-
/// " a string" + newline +
965-
/// " for" + newline +
966-
/// " wrapping" + newline +
967-
/// " 0123456789" + newline +
968-
/// " 0123"
969-
/// </remarks>
970-
/// <returns>A string that has been word-wrapped with padding on each line to indent it</returns>
971-
private static string WrapAndIndentText(string input,int indentLevel,int columnWidth)
972-
{
973-
//start by splitting at newlines and then reinserting the newline as a separate word
974-
//Note that on the input side, we can't assume the line-break style at run time so we have to
975-
//be able to handle both. We cant use Environment.NewLine because that changes at
976-
//_runtime_ and may not match the line-break style that was compiled in
977-
var lines = input
978-
.Replace("\r","")
979-
.Split(new[] {'\n'}, StringSplitOptions.None);
980-
var lineCount = lines.Length;
981-
982-
var tokens = lines
983-
.Zip(new string[lineCount], (a, _) => new string[] {a, Environment.NewLine})
984-
.SelectMany(linePair=>linePair)
985-
.Take(lineCount * 2 - 1);
986-
987-
//split into words
988-
var words= tokens
989-
.SelectMany(l=>l.Split(' '));
990-
991-
//create a list of individual indented lines
992-
var wrappedLines = words
993-
.Aggregate<string,List<StringBuilder>>(
994-
new List<StringBuilder>(),
995-
(lineList,word)=>AddWordToLastLineOrCreateNewLineIfNecessary(lineList,word,columnWidth)
996-
)
997-
.Select(builder => indentLevel.Spaces() + builder.ToString().TrimEnd());
998-
999-
//return the whole thing as a single string
1000-
return string.Join(Environment.NewLine,wrappedLines);
1001-
}
1002-
1003-
/// <summary>
1004-
/// When presented with a word, either append to the last line in the list or start a new line
1005-
/// </summary>
1006-
/// <param name="lines">A list of stringbuilders containing results so far</param>
1007-
/// <param name="word">The individual word to append</param>
1008-
/// <param name="columnWidth">The usable text space</param>
1009-
/// <remarks>
1010-
/// The 'word' can actually be an empty string or a linefeed. It's important to keep these -
1011-
/// empty strings allow us to preserve indentation and extra spaces within a line and linefeeds
1012-
/// allow us to honour the users formatting wishes when the pass in multi-line helptext.
1013-
/// </remarks>
1014-
/// <returns>The same list as is passed in</returns>
1015-
private static List<StringBuilder> AddWordToLastLineOrCreateNewLineIfNecessary(List<StringBuilder> lines, string word,int columnWidth)
1016-
{
1017-
if (word == Environment.NewLine)
1018-
{
1019-
//A newline token just means advance to the next line.
1020-
lines.Add(new StringBuilder());
1021-
return lines;
1022-
}
1023-
//The current indentLevel is based on the previous line.
1024-
var previousLine = lines.LastOrDefault()?.ToString() ??string.Empty;
1025-
var currentIndentLevel = previousLine.Length - previousLine.TrimStart().Length;
1026-
1027-
var wouldWrap = !lines.Any() || previousLine.Length + word.Length > columnWidth;
1028-
1029-
if (!wouldWrap)
1030-
{
1031-
//The usual case is we just append the 'word' and a space to the current line
1032-
//Note that trailing spaces will get removed later when we turn the line list
1033-
//into a single string
1034-
lines.Last().Append(word + ' ');
1035-
}
1036-
else
1037-
{
1038-
//The 'while' here is to take account of the possibility of someone providing a word
1039-
//which just can't fit in the current column. In that case we just split it at the
1040-
//column end.
1041-
//That's a rare case though - most of the time we'll succeed in a single pass without
1042-
//having to split
1043-
while (word.Length >0)
1044-
{
1045-
var availableCharacters = Math.Min(columnWidth - currentIndentLevel,word.Length);
1046-
1047-
var segmentToAdd = currentIndentLevel.Spaces() +
1048-
word.Substring(0, availableCharacters) + ' ';
1049-
1050-
lines.Add(new StringBuilder(segmentToAdd));
1051-
word = word.Substring(availableCharacters);
1052-
}
1053-
}
1054-
return lines;
1055-
}
1056-
952+
1057953

1058954
}
1059955
}
956+
957+
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using CommandLine.Infrastructure;
6+
7+
namespace CommandLine.Text
8+
{
9+
/// <summary>
10+
/// A utility class to word-wrap and indent blocks of text
11+
/// </summary>
12+
public class TextWrapper
13+
{
14+
private string[] lines;
15+
public TextWrapper(string input)
16+
{
17+
//start by splitting at newlines and then reinserting the newline as a separate word
18+
//Note that on the input side, we can't assume the line-break style at run time so we have to
19+
//be able to handle both. We can't use Environment.NewLine because that changes at
20+
//_runtime_ and may not match the line-break style that was compiled in
21+
lines = input
22+
.Replace("\r","")
23+
.Split(new[] {'\n'}, StringSplitOptions.None);
24+
}
25+
26+
/// <summary>
27+
/// Splits a string into a words and performs wrapping while also preserving line-breaks and sub-indentation
28+
/// </summary>
29+
/// <param name="columnWidth">The number of characters we can use for text</param>
30+
/// <remarks>
31+
/// This method attempts to wrap text without breaking words
32+
/// For example, if columnWidth is 10 , the input
33+
/// "a string for wrapping 01234567890123"
34+
/// would return
35+
/// "a string
36+
/// "for
37+
/// "wrapping
38+
/// "0123456789
39+
/// "0123"
40+
/// </remarks>
41+
/// <returns>this</returns>
42+
public TextWrapper WordWrap(int columnWidth)
43+
{
44+
45+
lines= lines
46+
.SelectMany(line => WordWrapLine(line, columnWidth))
47+
.ToArray();
48+
return this;
49+
}
50+
51+
/// <summary>
52+
/// Indent all lines in the TextWrapper by the desired number of spaces
53+
/// </summary>
54+
/// <param name="numberOfSpaces">The number of spaces to indent by</param>
55+
/// <returns>this</returns>
56+
public TextWrapper Indent(int numberOfSpaces)
57+
{
58+
lines = lines
59+
.Select(line => numberOfSpaces.Spaces() + line)
60+
.ToArray();
61+
return this;
62+
}
63+
64+
/// <summary>
65+
/// Returns the current state of the TextWrapper as a string
66+
/// </summary>
67+
/// <returns></returns>
68+
public string ToText()
69+
{
70+
//return the whole thing as a single string
71+
return string.Join(Environment.NewLine,lines);
72+
}
73+
74+
/// <summary>
75+
/// Convenience method to wraps and indent a string in a single operation
76+
/// </summary>
77+
/// <param name="input">The string to operate on</param>
78+
/// <param name="indentLevel">The number of spaces to indent by</param>
79+
/// <param name="columnWidth">The width of the column used for wrapping</param>
80+
/// <remarks>
81+
/// The string is wrapped _then_ indented so the columnWidth is the width of the
82+
/// usable text block, and does NOT include the indentLevel.
83+
/// </remarks>
84+
/// <returns>the processed string</returns>
85+
public static string WrapAndIndentText(string input, int indentLevel,int columnWidth)
86+
{
87+
return new TextWrapper(input)
88+
.WordWrap(columnWidth)
89+
.Indent(indentLevel)
90+
.ToText();
91+
}
92+
93+
94+
private string [] WordWrapLine(string line,int columnWidth)
95+
{
96+
//create a list of individual lines generated from the supplied line
97+
98+
//When handling sub-indentation we must always reserve at least one column for text!
99+
var unindentedLine = line.TrimStart();
100+
var currentIndentLevel = Math.Min(line.Length - unindentedLine.Length,columnWidth-1) ;
101+
columnWidth -= currentIndentLevel;
102+
103+
return unindentedLine.Split(' ')
104+
.Aggregate(
105+
new List<StringBuilder>(),
106+
(lineList, word) => AddWordToLastLineOrCreateNewLineIfNecessary(lineList, word, columnWidth)
107+
)
108+
.Select(builder => currentIndentLevel.Spaces()+builder.ToString().TrimEnd())
109+
.ToArray();
110+
}
111+
112+
/// <summary>
113+
/// When presented with a word, either append to the last line in the list or start a new line
114+
/// </summary>
115+
/// <param name="lines">A list of StringBuilders containing results so far</param>
116+
/// <param name="word">The individual word to append</param>
117+
/// <param name="columnWidth">The usable text space</param>
118+
/// <remarks>
119+
/// The 'word' can actually be an empty string. It's important to keep these -
120+
/// empty strings allow us to preserve indentation and extra spaces within a line.
121+
/// </remarks>
122+
/// <returns>The same list as is passed in</returns>
123+
private static List<StringBuilder> AddWordToLastLineOrCreateNewLineIfNecessary(List<StringBuilder> lines, string word,int columnWidth)
124+
{
125+
//The current indentation level is based on the previous line but we need to be careful
126+
var previousLine = lines.LastOrDefault()?.ToString() ??string.Empty;
127+
128+
var wouldWrap = !lines.Any() || (word.Length>0 && previousLine.Length + word.Length > columnWidth);
129+
130+
if (!wouldWrap)
131+
{
132+
//The usual case is we just append the 'word' and a space to the current line
133+
//Note that trailing spaces will get removed later when we turn the line list
134+
//into a single string
135+
lines.Last().Append(word + ' ');
136+
}
137+
else
138+
{
139+
//The 'while' here is to take account of the possibility of someone providing a word
140+
//which just can't fit in the current column. In that case we just split it at the
141+
//column end.
142+
//That's a rare case though - most of the time we'll succeed in a single pass without
143+
//having to split
144+
//Note that we always do at least one pass even if the 'word' is empty in order to
145+
//honour sub-indentation and extra spaces within strings
146+
do
147+
{
148+
var availableCharacters = Math.Min(columnWidth, word.Length);
149+
var segmentToAdd = LeftString(word,availableCharacters) + ' ';
150+
lines.Add(new StringBuilder(segmentToAdd));
151+
word = RightString(word,availableCharacters);
152+
} while (word.Length > 0);
153+
}
154+
return lines;
155+
}
156+
157+
158+
private static string RightString(string str,int n)
159+
{
160+
return (n >= str.Length || str.Length==0)
161+
? string.Empty
162+
: str.Substring(n);
163+
}
164+
165+
private static string LeftString(string str,int n)
166+
{
167+
168+
return (n >= str.Length || str.Length==0)
169+
? str
170+
: str.Substring(0,n);
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)