Skip to content

Commit 18e5e78

Browse files
committed
Improve support for multiline help text
1 parent 4d69a38 commit 18e5e78

4 files changed

Lines changed: 240 additions & 86 deletions

File tree

src/CommandLine/Text/HelpText.cs

Lines changed: 129 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ public class HelpText
2121
{
2222
private const int BuilderCapacity = 128;
2323
private const int DefaultMaximumLength = 80; // default console width
24+
/// <summary>
25+
/// The number of spaces between an option and its associated help text
26+
/// </summary>
27+
private const int OptionToHelpTextSeparatorWidth = 4;
28+
/// <summary>
29+
/// The width of the option prefix (either "--" or " "
30+
/// </summary>
31+
private const int OptionPrefixWidth = 2;
32+
/// <summary>
33+
/// The total amount of extra space that needs to accounted for when indenting Option help text
34+
/// </summary>
35+
private const int TotalOptionPadding = OptionToHelpTextSeparatorWidth + OptionPrefixWidth;
2436
private readonly StringBuilder preOptionsHelp;
2537
private readonly StringBuilder postOptionsHelp;
2638
private readonly SentenceBuilder sentenceBuilder;
@@ -608,7 +620,7 @@ public static IEnumerable<string> RenderUsageTextAsLines<T>(ParserResult<T> pars
608620
var styles = example.GetFormatStylesOrDefault();
609621
foreach (var s in styles)
610622
{
611-
var commandLine = new StringBuilder(2.Spaces())
623+
var commandLine = new StringBuilder(OptionPrefixWidth.Spaces())
612624
.Append(appAlias)
613625
.Append(' ')
614626
.Append(Parser.Default.FormatCommandLine(example.Sample,
@@ -645,7 +657,7 @@ public override string ToString()
645657
.ToString();
646658
}
647659

648-
internal static void AddLine(StringBuilder builder, string value, int maximumLength)
660+
internal static void AddLine(StringBuilder builder, string value, int maximumLength)
649661
{
650662
if (builder == null)
651663
{
@@ -665,37 +677,7 @@ internal static void AddLine(StringBuilder builder, string value, int maximumLen
665677
value = value.TrimEnd();
666678

667679
builder.AppendWhen(builder.Length > 0, Environment.NewLine);
668-
do
669-
{
670-
var wordBuffer = 0;
671-
var words = value.Split(' ');
672-
for (var i = 0; i < words.Length; i++)
673-
{
674-
if (words[i].Length < (maximumLength - wordBuffer))
675-
{
676-
builder.Append(words[i]);
677-
wordBuffer += words[i].Length;
678-
if ((maximumLength - wordBuffer) > 1 && i != words.Length - 1)
679-
{
680-
builder.Append(" ");
681-
wordBuffer++;
682-
}
683-
}
684-
else if (words[i].Length >= maximumLength && wordBuffer == 0)
685-
{
686-
builder.Append(words[i].Substring(0, maximumLength));
687-
wordBuffer = maximumLength;
688-
break;
689-
}
690-
else
691-
break;
692-
}
693-
value = value.Substring(Math.Min(wordBuffer, value.Length));
694-
builder.AppendWhen(value.Length > 0, Environment.NewLine);
695-
}
696-
while (value.Length > maximumLength);
697-
698-
builder.Append(value);
680+
builder.Append(WrapAndIndentText(value, 0, maximumLength));
699681
}
700682

701683
private IEnumerable<Specification> GetSpecificationsFromType(Type type)
@@ -748,7 +730,7 @@ private IEnumerable<Specification> AdaptVerbsToSpecifications(IEnumerable<Type>
748730
return optionSpecs;
749731
}
750732

751-
private HelpText AddOptionsImpl(
733+
private HelpText AddOptionsImpl(
752734
IEnumerable<Specification> specifications,
753735
string requiredWord,
754736
int maximumLength)
@@ -757,7 +739,7 @@ private HelpText AddOptionsImpl(
757739

758740
optionsHelp = new StringBuilder(BuilderCapacity);
759741

760-
var remainingSpace = maximumLength - (maxLength + 6);
742+
var remainingSpace = maximumLength - (maxLength + TotalOptionPadding);
761743

762744
specifications.ForEach(
763745
option =>
@@ -809,7 +791,7 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe
809791

810792
optionsHelp
811793
.Append(name.Length < maxLength ? name.ToString().PadRight(maxLength) : name.ToString())
812-
.Append(" ");
794+
.Append(OptionToHelpTextSeparatorWidth.Spaces());
813795

814796
var optionHelpText = specification.HelpText;
815797

@@ -821,44 +803,13 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe
821803

822804
if (specification.Required)
823805
optionHelpText = "{0} ".FormatInvariant(requiredWord) + optionHelpText;
824-
825-
if (!string.IsNullOrEmpty(optionHelpText))
826-
{
827-
do
828-
{
829-
var wordBuffer = 0;
830-
var words = optionHelpText.Split(' ');
831-
for (var i = 0; i < words.Length; i++)
832-
{
833-
if (words[i].Length < (widthOfHelpText - wordBuffer))
834-
{
835-
optionsHelp.Append(words[i]);
836-
wordBuffer += words[i].Length;
837-
if ((widthOfHelpText - wordBuffer) > 1 && i != words.Length - 1)
838-
{
839-
optionsHelp.Append(" ");
840-
wordBuffer++;
841-
}
842-
}
843-
else if (words[i].Length >= widthOfHelpText && wordBuffer == 0)
844-
{
845-
optionsHelp.Append(words[i].Substring(0, widthOfHelpText));
846-
wordBuffer = widthOfHelpText;
847-
break;
848-
}
849-
else
850-
break;
851-
}
852-
853-
optionHelpText = optionHelpText.Substring(Math.Min(wordBuffer, optionHelpText.Length)).Trim();
854-
optionsHelp.AppendWhen(optionHelpText.Length > 0, Environment.NewLine,
855-
new string(' ', maxLength + 6));
856-
}
857-
while (optionHelpText.Length > widthOfHelpText);
858-
}
859-
806+
807+
//note that we need to indent trim the start of the string because it's going to be
808+
//appended to an existing line that is as long as the indent-level
809+
var indented = WrapAndIndentText(optionHelpText, maxLength+TotalOptionPadding, widthOfHelpText).TrimStart();
810+
860811
optionsHelp
861-
.Append(optionHelpText)
812+
.Append(indented)
862813
.Append(Environment.NewLine)
863814
.AppendWhen(additionalNewLineAfterOption, Environment.NewLine);
864815

@@ -944,13 +895,13 @@ private int GetMaxOptionLength(OptionSpecification spec)
944895
{
945896
specLength += spec.LongName.Length;
946897
if (AddDashesToOption)
947-
specLength += 2;
898+
specLength += OptionPrefixWidth;
948899

949900
specLength += metaLength;
950901
}
951902

952903
if (hasShort && hasLong)
953-
specLength += 2; // ", "
904+
specLength += OptionPrefixWidth;
954905

955906
return specLength;
956907
}
@@ -997,5 +948,107 @@ private static string FormatDefaultValue<T>(T value)
997948
? builder.ToString(0, builder.Length - 1)
998949
: string.Empty;
999950
}
951+
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+
var lines = input.Split(new[] {Environment.NewLine}, StringSplitOptions.None);
975+
var lineCount = lines.Length;
976+
977+
var tokens = lines
978+
.Zip(new string[lineCount], (a, _) => new string[] {a, Environment.NewLine})
979+
.SelectMany(linePair=>linePair)
980+
.Take(lineCount * 2 - 1);
981+
982+
//split into words
983+
var words= tokens
984+
.SelectMany(l=>l.Split(' '));
985+
986+
//create a list of individual indented lines
987+
var wrappedLines = words
988+
.Aggregate<string,List<StringBuilder>>(
989+
new List<StringBuilder>(),
990+
(lineList,word)=>AddWordToLastLineOrCreateNewLineIfNecessary(lineList,word,columnWidth)
991+
)
992+
.Select(builder => indentLevel.Spaces() + builder.ToString().TrimEnd());
993+
994+
//return the whole thing as a single string
995+
return string.Join(Environment.NewLine,wrappedLines);
996+
}
997+
998+
/// <summary>
999+
/// When presented with a word, either append to the last line in the list or start a new line
1000+
/// </summary>
1001+
/// <param name="lines">A list of stringbuilders containing results so far</param>
1002+
/// <param name="word">The individual word to append</param>
1003+
/// <param name="columnWidth">The usable text space</param>
1004+
/// <remarks>
1005+
/// The 'word' can actually be an empty string or a linefeed. It's important to keep these -
1006+
/// empty strings allow us to preserve indentation and extra spaces within a line and linefeeds
1007+
/// allow us to honour the users formatting wishes when the pass in multi-line helptext.
1008+
/// </remarks>
1009+
/// <returns>The same list as is passed in</returns>
1010+
private static List<StringBuilder> AddWordToLastLineOrCreateNewLineIfNecessary(List<StringBuilder> lines, string word,int columnWidth)
1011+
{
1012+
if (word == Environment.NewLine)
1013+
{
1014+
//A newline token just means advance to the next line.
1015+
lines.Add(new StringBuilder());
1016+
return lines;
1017+
}
1018+
//The current indentLevel is based on the previous line.
1019+
var previousLine = lines.LastOrDefault()?.ToString() ??string.Empty;
1020+
var currentIndentLevel = previousLine.Length - previousLine.TrimStart().Length;
1021+
1022+
var wouldWrap = !lines.Any() || previousLine.Length + word.Length > columnWidth;
1023+
1024+
if (!wouldWrap)
1025+
{
1026+
//The usual case is we just append the 'word' and a space to the current line
1027+
//Note that trailing spaces will get removed later when we turn the line list
1028+
//into a single string
1029+
lines.Last().Append(word + ' ');
1030+
}
1031+
else
1032+
{
1033+
//The 'while' here is to take account of the possibility of someone providing a word
1034+
//which just can't fit in the current column. In that case we just split it at the
1035+
//column end.
1036+
//That's a rare case though - most of the time we'll succeed in a single pass without
1037+
//having to split
1038+
while (word.Length >0)
1039+
{
1040+
var availableCharacters = Math.Min(columnWidth - currentIndentLevel,word.Length);
1041+
1042+
var segmentToAdd = currentIndentLevel.Spaces() +
1043+
word.Substring(0, availableCharacters) + ' ';
1044+
1045+
lines.Add(new StringBuilder(segmentToAdd));
1046+
word = word.Substring(availableCharacters);
1047+
}
1048+
}
1049+
return lines;
1050+
}
1051+
1052+
10001053
}
1001-
}
1054+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace CommandLine.Tests.Fakes
2+
{
3+
public class HelpTextWithLineBreaksAndSubIndentation_Options
4+
{
5+
6+
[Option(HelpText = @"This is a help text description where we want:
7+
* The left pad after a linebreak to be honoured and the indentation to be preserved across to the next line
8+
* The ability to return to no indent.
9+
Like this.")]
10+
public string StringValue { get; set; }
11+
12+
}
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace CommandLine.Tests.Fakes
2+
{
3+
public class HelpTextWithLineBreaks_Options
4+
{
5+
[Option(HelpText =
6+
@"This is a help text description.
7+
It has multiple lines.
8+
We also want to ensure that indentation is correct.")]
9+
public string StringValue { get; set; }
10+
11+
12+
[Option(HelpText = @"This is a help text description where we want
13+
The left pad after a linebreak to be honoured so that
14+
we can sub-indent within a description.")]
15+
public string StringValu2 { get; set; }
16+
17+
18+
[Option(HelpText = @"This is a help text description where we want
19+
The left pad after a linebreak to be honoured and the indentation to be preserved across to the next line in a way that looks pleasing")]
20+
public string StringValu3 { get; set; }
21+
22+
}
23+
}

0 commit comments

Comments
 (0)