Skip to content

Commit

Permalink
Merge pull request #456 from NeilMacMullen/feature_improve_support__f…
Browse files Browse the repository at this point in the history
…or_multiline_helptext

Improve support for multiline help text
  • Loading branch information
moh-hassan authored Jun 30, 2019
2 parents 8579025 + 58ae1f6 commit e9340b0
Show file tree
Hide file tree
Showing 9 changed files with 610 additions and 94 deletions.
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

0 comments on commit e9340b0

Please sign in to comment.