Skip to content

Improve support for multiline help text #456

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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
104 changes: 30 additions & 74 deletions src/CommandLine/Text/HelpText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ public class HelpText
{
private const int BuilderCapacity = 128;
private const int DefaultMaximumLength = 80; // default console width
/// <summary>
/// The number of spaces between an option and its associated help text
/// </summary>
private const int OptionToHelpTextSeparatorWidth = 4;
/// <summary>
/// The width of the option prefix (either "--" or " "
/// </summary>
private const int OptionPrefixWidth = 2;
/// <summary>
/// The total amount of extra space that needs to accounted for when indenting Option help text
/// </summary>
private const int TotalOptionPadding = OptionToHelpTextSeparatorWidth + OptionPrefixWidth;
private readonly StringBuilder preOptionsHelp;
private readonly StringBuilder postOptionsHelp;
private readonly SentenceBuilder sentenceBuilder;
Expand Down Expand Up @@ -608,7 +620,7 @@ public static IEnumerable<string> RenderUsageTextAsLines<T>(ParserResult<T> pars
var styles = example.GetFormatStylesOrDefault();
foreach (var s in styles)
{
var commandLine = new StringBuilder(2.Spaces())
var commandLine = new StringBuilder(OptionPrefixWidth.Spaces())
.Append(appAlias)
.Append(' ')
.Append(Parser.Default.FormatCommandLine(example.Sample,
Expand Down Expand Up @@ -665,37 +677,7 @@ internal static void AddLine(StringBuilder builder, string value, int maximumLen
value = value.TrimEnd();

builder.AppendWhen(builder.Length > 0, Environment.NewLine);
do
{
var wordBuffer = 0;
var words = value.Split(' ');
for (var i = 0; i < words.Length; i++)
{
if (words[i].Length < (maximumLength - wordBuffer))
{
builder.Append(words[i]);
wordBuffer += words[i].Length;
if ((maximumLength - wordBuffer) > 1 && i != words.Length - 1)
{
builder.Append(" ");
wordBuffer++;
}
}
else if (words[i].Length >= maximumLength && wordBuffer == 0)
{
builder.Append(words[i].Substring(0, maximumLength));
wordBuffer = maximumLength;
break;
}
else
break;
}
value = value.Substring(Math.Min(wordBuffer, value.Length));
builder.AppendWhen(value.Length > 0, Environment.NewLine);
}
while (value.Length > maximumLength);

builder.Append(value);
builder.Append(TextWrapper.WrapAndIndentText(value, 0, maximumLength));
}

private IEnumerable<Specification> GetSpecificationsFromType(Type type)
Expand Down Expand Up @@ -757,7 +739,7 @@ private HelpText AddOptionsImpl(

optionsHelp = new StringBuilder(BuilderCapacity);

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

specifications.ForEach(
option =>
Expand Down Expand Up @@ -809,7 +791,7 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe

optionsHelp
.Append(name.Length < maxLength ? name.ToString().PadRight(maxLength) : name.ToString())
.Append(" ");
.Append(OptionToHelpTextSeparatorWidth.Spaces());

var optionHelpText = specification.HelpText;

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

if (specification.Required)
optionHelpText = "{0} ".FormatInvariant(requiredWord) + optionHelpText;

if (!string.IsNullOrEmpty(optionHelpText))
{
do
{
var wordBuffer = 0;
var words = optionHelpText.Split(' ');
for (var i = 0; i < words.Length; i++)
{
if (words[i].Length < (widthOfHelpText - wordBuffer))
{
optionsHelp.Append(words[i]);
wordBuffer += words[i].Length;
if ((widthOfHelpText - wordBuffer) > 1 && i != words.Length - 1)
{
optionsHelp.Append(" ");
wordBuffer++;
}
}
else if (words[i].Length >= widthOfHelpText && wordBuffer == 0)
{
optionsHelp.Append(words[i].Substring(0, widthOfHelpText));
wordBuffer = widthOfHelpText;
break;
}
else
break;
}

optionHelpText = optionHelpText.Substring(Math.Min(wordBuffer, optionHelpText.Length)).Trim();
optionsHelp.AppendWhen(optionHelpText.Length > 0, Environment.NewLine,
new string(' ', maxLength + 6));
}
while (optionHelpText.Length > widthOfHelpText);
}


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

optionsHelp
.Append(optionHelpText)
.Append(indented)
.Append(Environment.NewLine)
.AppendWhen(additionalNewLineAfterOption, Environment.NewLine);

Expand Down Expand Up @@ -944,13 +895,13 @@ private int GetMaxOptionLength(OptionSpecification spec)
{
specLength += spec.LongName.Length;
if (AddDashesToOption)
specLength += 2;
specLength += OptionPrefixWidth;

specLength += metaLength;
}

if (hasShort && hasLong)
specLength += 2; // ", "
specLength += OptionPrefixWidth;

return specLength;
}
Expand Down Expand Up @@ -997,5 +948,10 @@ private static string FormatDefaultValue<T>(T value)
? builder.ToString(0, builder.Length - 1)
: string.Empty;
}



}
}
}


179 changes: 179 additions & 0 deletions src/CommandLine/Text/TextWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CommandLine.Infrastructure;

namespace CommandLine.Text
{
/// <summary>
/// A utility class to word-wrap and indent blocks of text
/// </summary>
public class TextWrapper
{
private string[] lines;
public TextWrapper(string input)
{
//start by splitting at newlines and then reinserting the newline as a separate word
//Note that on the input side, we can't assume the line-break style at run time so we have to
//be able to handle both. We can't use Environment.NewLine because that changes at
//_runtime_ and may not match the line-break style that was compiled in
lines = input
.Replace("\r","")
.Split(new[] {'\n'}, StringSplitOptions.None);
}

/// <summary>
/// Splits a string into a words and performs wrapping while also preserving line-breaks and sub-indentation
/// </summary>
/// <param name="columnWidth">The number of characters we can use for text</param>
/// <remarks>
/// This method attempts to wrap text without breaking words
/// For example, if columnWidth is 10 , the input
/// "a string for wrapping 01234567890123"
/// would return
/// "a string
/// "for
/// "wrapping
/// "0123456789
/// "0123"
/// </remarks>
/// <returns>this</returns>
public TextWrapper WordWrap(int columnWidth)
{
//ensure we always use at least 1 column even if the client has told us there's no space available
columnWidth = Math.Max(1, columnWidth);
lines= lines
.SelectMany(line => WordWrapLine(line, columnWidth))
.ToArray();
return this;
}

/// <summary>
/// Indent all lines in the TextWrapper by the desired number of spaces
/// </summary>
/// <param name="numberOfSpaces">The number of spaces to indent by</param>
/// <returns>this</returns>
public TextWrapper Indent(int numberOfSpaces)
{
lines = lines
.Select(line => numberOfSpaces.Spaces() + line)
.ToArray();
return this;
}

/// <summary>
/// Returns the current state of the TextWrapper as a string
/// </summary>
/// <returns></returns>
public string ToText()
{
//return the whole thing as a single string
return string.Join(Environment.NewLine,lines);
}

/// <summary>
/// Convenience method to wraps and indent a string in a single operation
/// </summary>
/// <param name="input">The string to operate on</param>
/// <param name="indentLevel">The number of spaces to indent by</param>
/// <param name="columnWidth">The width of the column used for wrapping</param>
/// <remarks>
/// The string is wrapped _then_ indented so the columnWidth is the width of the
/// usable text block, and does NOT include the indentLevel.
/// </remarks>
/// <returns>the processed string</returns>
public static string WrapAndIndentText(string input, int indentLevel,int columnWidth)
{
return new TextWrapper(input)
.WordWrap(columnWidth)
.Indent(indentLevel)
.ToText();
}


private string [] WordWrapLine(string line,int columnWidth)
{
//create a list of individual lines generated from the supplied line

//When handling sub-indentation we must always reserve at least one column for text!
var unindentedLine = line.TrimStart();
var currentIndentLevel = Math.Min(line.Length - unindentedLine.Length,columnWidth-1) ;
columnWidth -= currentIndentLevel;

return unindentedLine.Split(' ')
.Aggregate(
new List<StringBuilder>(),
(lineList, word) => AddWordToLastLineOrCreateNewLineIfNecessary(lineList, word, columnWidth)
)
.Select(builder => currentIndentLevel.Spaces()+builder.ToString().TrimEnd())
.ToArray();
}

/// <summary>
/// When presented with a word, either append to the last line in the list or start a new line
/// </summary>
/// <param name="lines">A list of StringBuilders containing results so far</param>
/// <param name="word">The individual word to append</param>
/// <param name="columnWidth">The usable text space</param>
/// <remarks>
/// The 'word' can actually be an empty string. It's important to keep these -
/// empty strings allow us to preserve indentation and extra spaces within a line.
/// </remarks>
/// <returns>The same list as is passed in</returns>
private static List<StringBuilder> AddWordToLastLineOrCreateNewLineIfNecessary(List<StringBuilder> lines, string word,int columnWidth)
{
//The current indentation level is based on the previous line but we need to be careful
var previousLine = lines.LastOrDefault()?.ToString() ??string.Empty;

var wouldWrap = !lines.Any() || (word.Length>0 && previousLine.Length + word.Length > columnWidth);

if (!wouldWrap)
{
//The usual case is we just append the 'word' and a space to the current line
//Note that trailing spaces will get removed later when we turn the line list
//into a single string
lines.Last().Append(word + ' ');
}
else
{
//The 'while' here is to take account of the possibility of someone providing a word
//which just can't fit in the current column. In that case we just split it at the
//column end.
//That's a rare case though - most of the time we'll succeed in a single pass without
//having to split
//Note that we always do at least one pass even if the 'word' is empty in order to
//honour sub-indentation and extra spaces within strings
do
{
var availableCharacters = Math.Min(columnWidth, word.Length);
var segmentToAdd = LeftString(word,availableCharacters) + ' ';
lines.Add(new StringBuilder(segmentToAdd));
word = RightString(word,availableCharacters);
} while (word.Length > 0);
}
return lines;
}


/// <summary>
/// Return the right part of a string in a way that compensates for Substring's deficiencies
/// </summary>
private static string RightString(string str,int n)
{
return (n >= str.Length || str.Length==0)
? string.Empty
: str.Substring(n);
}
/// <summary>
/// Return the left part of a string in a way that compensates for Substring's deficiencies
/// </summary>
private static string LeftString(string str,int n)
{

return (n >= str.Length || str.Length==0)
? str
: str.Substring(0,n);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace CommandLine.Tests.Fakes
{
public class HelpTextWithLineBreaksAndSubIndentation_Options
{

[Option(HelpText = @"This is a help text description where we want:
* The left pad after a linebreak to be honoured and the indentation to be preserved across to the next line
* The ability to return to no indent.
Like this.")]
public string StringValue { get; set; }

}
}
23 changes: 23 additions & 0 deletions tests/CommandLine.Tests/Fakes/HelpTextWithLineBreaks_Options.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace CommandLine.Tests.Fakes
{
public class HelpTextWithLineBreaks_Options
{
[Option(HelpText =
@"This is a help text description.
It has multiple lines.
We also want to ensure that indentation is correct.")]
public string StringValue { get; set; }


[Option(HelpText = @"This is a help text description where we want
The left pad after a linebreak to be honoured so that
we can sub-indent within a description.")]
public string StringValu2 { get; set; }


[Option(HelpText = @"This is a help text description where we want
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")]
public string StringValu3 { get; set; }

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace CommandLine.Tests.Fakes
{
public class HelpTextWithMixedLineBreaks_Options
{
[Option(HelpText =
"This is a help text description\n It has multiple lines.\r\n Third line")]
public string StringValue { get; set; }
}
}
Loading