Skip to content

Commit 5b097ad

Browse files
kyle-radernatemcmaster
authored andcommitted
fix: wrap help text descriptions based on console width (#248)
1 parent e67dd33 commit 5b097ad

File tree

5 files changed

+243
-16
lines changed

5 files changed

+243
-16
lines changed

src/CommandLineUtils/HelpText/DefaultHelpTextGenerator.cs

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ namespace McMaster.Extensions.CommandLineUtils.HelpText
1414
/// </summary>
1515
public class DefaultHelpTextGenerator : IHelpTextGenerator
1616
{
17+
/// <summary>
18+
/// The number of spaces between columns.
19+
/// </summary>
20+
protected const int ColumnSeparatorLength = 2;
21+
22+
/// <summary>
23+
/// The hanging indent writer used for formatting indented and wrapped
24+
/// descriptions for options and arguments.
25+
/// </summary>
26+
protected HangingIndentWriter? indentWriter;
27+
1728
/// <summary>
1829
/// A singleton instance of <see cref="DefaultHelpTextGenerator" />.
1930
/// </summary>
@@ -73,12 +84,14 @@ protected virtual void GenerateBody(
7384
var options = application.GetOptions().Where(o => o.ShowInHelpText).ToList();
7485
var commands = application.Commands.Where(c => c.ShowInHelpText).ToList();
7586

76-
var firstColumnWidth = 2 + Math.Max(
87+
var firstColumnWidth = ColumnSeparatorLength + Math.Max(
7788
arguments.Count > 0 ? arguments.Max(a => a.Name?.Length ?? 0) : 0,
7889
Math.Max(
7990
options.Count > 0 ? options.Max(o => Format(o).Length) : 0,
8091
commands.Count > 0 ? commands.Max(c => c.Name?.Length ?? 0) : 0));
8192

93+
indentWriter = new HangingIndentWriter(firstColumnWidth + ColumnSeparatorLength, maxLineLength: TryGetConsoleWidth());
94+
8295
GenerateUsage(application, output, arguments, options, commands);
8396
GenerateArguments(application, output, arguments, firstColumnWidth);
8497
GenerateOptions(application, output, options, firstColumnWidth);
@@ -157,12 +170,10 @@ protected virtual void GenerateArguments(
157170
output.WriteLine("Arguments:");
158171
var outputFormat = string.Format(" {{0, -{0}}}{{1}}", firstColumnWidth);
159172

160-
var newLineWithMessagePadding = Environment.NewLine + new string(' ', firstColumnWidth + 2);
161-
162173
foreach (var arg in visibleArguments)
163174
{
164-
var message = string.Format(outputFormat, arg.Name, arg.Description);
165-
message = message.Replace(Environment.NewLine, newLineWithMessagePadding);
175+
var wrappedDescription = indentWriter!.Write(arg.Description);
176+
var message = string.Format(outputFormat, arg.Name, wrappedDescription);
166177

167178
output.Write(message);
168179
output.WriteLine();
@@ -189,12 +200,10 @@ protected virtual void GenerateOptions(
189200
output.WriteLine("Options:");
190201
var outputFormat = string.Format(" {{0, -{0}}}{{1}}", firstColumnWidth);
191202

192-
var newLineWithMessagePadding = Environment.NewLine + new string(' ', firstColumnWidth + 2);
193-
194203
foreach (var opt in visibleOptions)
195204
{
196-
var message = string.Format(outputFormat, Format(opt), opt.Description);
197-
message = message.Replace(Environment.NewLine, newLineWithMessagePadding);
205+
var wrappedDescription = indentWriter!.Write(opt.Description);
206+
var message = string.Format(outputFormat, Format(opt), wrappedDescription);
198207

199208
output.Write(message);
200209
output.WriteLine();
@@ -221,15 +230,13 @@ protected virtual void GenerateCommands(
221230
output.WriteLine("Commands:");
222231
var outputFormat = string.Format(" {{0, -{0}}}{{1}}", firstColumnWidth);
223232

224-
var newLineWithMessagePadding = Environment.NewLine + new string(' ', firstColumnWidth + 2);
225-
226233
var orderedCommands = SortCommandsByName
227234
? visibleCommands.OrderBy(c => c.Name).ToList()
228235
: visibleCommands;
229236
foreach (var cmd in orderedCommands)
230237
{
231-
var message = string.Format(outputFormat, cmd.Name, cmd.Description);
232-
message = message.Replace(Environment.NewLine, newLineWithMessagePadding);
238+
var wrappedDescription = indentWriter!.Write(cmd.Description);
239+
var message = string.Format(outputFormat, cmd.Name, wrappedDescription);
233240

234241
output.Write(message);
235242
output.WriteLine();
@@ -303,5 +310,23 @@ protected virtual string Format(CommandOption option)
303310
return sb.ToString();
304311
}
305312

313+
/// <summary>
314+
/// Get the Console width.
315+
/// </summary>
316+
/// <returns>BufferWidth or the default.</returns>
317+
private int? TryGetConsoleWidth()
318+
{
319+
try
320+
{
321+
return Console.BufferWidth;
322+
}
323+
catch (IOException)
324+
{
325+
// If there isn't a console - for instance in test enviornments
326+
// An IOException will be thrown trying to get the Console.BufferWidth.
327+
return null;
328+
}
329+
}
330+
306331
}
307332
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Text;
7+
8+
namespace McMaster.Extensions.CommandLineUtils.HelpText
9+
{
10+
/// <summary>
11+
/// A formatter for creating nicely wrapped descriptions for display on the command line in the second column
12+
/// of generated help text.
13+
/// </summary>
14+
public class HangingIndentWriter
15+
{
16+
/// <summary>
17+
/// The default console width used for wrapping if the width cannot be gotten from the Console.
18+
/// </summary>
19+
public const int DefaultConsoleWidth = 80;
20+
21+
private bool _indentFirstLine;
22+
private int _indentSize;
23+
private int _maxLineLength;
24+
private string _paddedLine;
25+
26+
/// <summary>
27+
/// A description formatter for dynamically wrapping the description to print in a CLI usage.
28+
/// </summary>
29+
/// <param name="indentSize">The indent size in spaces to use.</param>
30+
/// <param name="maxLineLength">The max length an indented line can be.
31+
/// Defaults to <see cref="DefaultConsoleWidth"/>.
32+
/// </param>
33+
/// <param name="indentFirstLine">If true, the first line of text will also be indented.</param>
34+
public HangingIndentWriter(int indentSize, int? maxLineLength = null, bool indentFirstLine = false)
35+
{
36+
_indentSize = indentSize;
37+
_maxLineLength = maxLineLength ?? DefaultConsoleWidth;
38+
_indentFirstLine = indentFirstLine;
39+
_paddedLine = Environment.NewLine + new string(' ', _indentSize);
40+
}
41+
42+
/// <summary>
43+
/// Dynamically wrap text between.
44+
/// </summary>
45+
/// <param name="input">The original description text.</param>
46+
/// <returns>Dynamically wrapped description with explicit newlines preserved.</returns>
47+
public string Write(string? input)
48+
{
49+
if (string.IsNullOrWhiteSpace(input))
50+
{
51+
return string.Empty;
52+
}
53+
54+
var lines = input
55+
.Split(new[] { "\n", "\r\n" }, StringSplitOptions.None)
56+
.Select(WrapSingle);
57+
58+
return (_indentFirstLine ? _paddedLine : string.Empty) + string.Join(_paddedLine, lines);
59+
}
60+
61+
/// <summary>
62+
/// Wrap a single line based on console width.
63+
/// </summary>
64+
/// <param name="original">The original description text.</param>
65+
/// <returns>Description text wrapped with padded newlines.</returns>
66+
private string WrapSingle(string original)
67+
{
68+
StringBuilder sb = new StringBuilder();
69+
var lineLength = _indentSize;
70+
foreach (var token in original.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
71+
{
72+
if (lineLength == _indentSize)
73+
{
74+
// At the beginning of a new padded line, just append token.
75+
}
76+
else if (lineLength + 1 + token.Length > _maxLineLength)
77+
{
78+
// Adding a space + token would push over console width, need a new line.
79+
sb.Append(_paddedLine);
80+
lineLength = _indentSize;
81+
}
82+
else
83+
{
84+
// We can add a space and the token so add the space.
85+
sb.Append(' ');
86+
lineLength++;
87+
}
88+
89+
sb.Append(token);
90+
lineLength += token.Length;
91+
}
92+
return sb.ToString();
93+
}
94+
}
95+
}

test/CommandLineUtils.Tests/CommandLineApplicationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,16 +677,16 @@ dotnet befuddle -- I Can Haz Confusion Arguments
677677
[Theory]
678678
[InlineData(new[] { "--version", "--flag" }, "1.0")]
679679
[InlineData(new[] { "-V", "-f" }, "1.0")]
680-
[InlineData(new[] { "--help", "--flag" }, "some flag")]
681-
[InlineData(new[] { "-h", "-f" }, "some flag")]
680+
[InlineData(new[] { "--help", "--flag" }, "some_flag")]
681+
[InlineData(new[] { "-h", "-f" }, "some_flag")]
682682
public void HelpAndVersionOptionStopProcessing(string[] input, string expectedOutData)
683683
{
684684
using (var outWriter = new StringWriter())
685685
{
686686
var app = new CommandLineApplication { Out = outWriter };
687687
app.HelpOption("-h --help");
688688
app.VersionOption("-V --version", "1", "1.0");
689-
var optFlag = app.Option("-f |--flag", "some flag", CommandOptionType.NoValue);
689+
var optFlag = app.Option("-f |--flag", "some_flag", CommandOptionType.NoValue);
690690

691691
app.Execute(input);
692692

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using FluentAssertions;
6+
using McMaster.Extensions.CommandLineUtils.HelpText;
7+
using Xunit;
8+
9+
namespace McMaster.Extensions.CommandLineUtils.Tests
10+
{
11+
public class HangingIndentWriterTests
12+
{
13+
private const int IndentSize = 20;
14+
private const int ConsoleWidth = 40; // A very skinny console for testing.
15+
private string _paddedNewline = Environment.NewLine + new string(' ', IndentSize);
16+
17+
private HangingIndentWriter Subject()
18+
{
19+
return new HangingIndentWriter(IndentSize, ConsoleWidth);
20+
}
21+
22+
private void AssertWrapBehavior(string original, string expected)
23+
{
24+
Subject().Write(original).Should().Be(expected);
25+
}
26+
27+
[Fact]
28+
public void EmptReturnsEmpty()
29+
{
30+
AssertWrapBehavior("", "");
31+
}
32+
33+
[Fact]
34+
public void SimpleOneLineJustWorks()
35+
{
36+
var originalText = "Verbosity setting";
37+
var expected = "Verbosity setting";
38+
AssertWrapBehavior(originalText, expected);
39+
}
40+
41+
[Fact]
42+
public void ItWrapsADescriptionBasedOnConsoleWidthAndFirstColumnSize()
43+
{
44+
var originalText = "This argument description is really long. It is a great argument. The best argument.";
45+
var expected = "This argument" +
46+
_paddedNewline + "description is" +
47+
_paddedNewline + "really long. It is a" +
48+
_paddedNewline + "great argument. The" +
49+
_paddedNewline + "best argument.";
50+
51+
AssertWrapBehavior(originalText, expected);
52+
}
53+
54+
[Fact]
55+
public void TheFirstLineCanAlsoBeIndented()
56+
{
57+
var originalText = "This argument description is really long. It is a great argument. The best argument.";
58+
var expected =
59+
_paddedNewline + "This argument" +
60+
_paddedNewline + "description is" +
61+
_paddedNewline + "really long. It is a" +
62+
_paddedNewline + "great argument. The" +
63+
_paddedNewline + "best argument.";
64+
65+
var subject = new HangingIndentWriter(IndentSize, maxLineLength: ConsoleWidth, indentFirstLine: true);
66+
subject.Write(originalText).Should().Be(expected);
67+
}
68+
69+
[Fact]
70+
public void LongRunningFirstLineStaysOnFirstLine()
71+
{
72+
var originalText = "SomeReallyLongWordWithNoSpacesAtAll the end.";
73+
var expected = "SomeReallyLongWordWithNoSpacesAtAll" +
74+
_paddedNewline + "the end.";
75+
76+
AssertWrapBehavior(originalText, expected);
77+
}
78+
79+
[Theory]
80+
[InlineData("I want\nmy own newlines\nplease the end.\nBut long lines do still get broken up correctly!")]
81+
[InlineData("I want\r\nmy own newlines\r\nplease the end.\r\nBut long lines do still get broken up correctly!")]
82+
public void ExplicitNewLinesArePreservedRegardlessOfType(string originalText)
83+
{
84+
var expected = "I want" +
85+
_paddedNewline + "my own newlines" +
86+
_paddedNewline + "please the end." +
87+
_paddedNewline + "But long lines do" +
88+
_paddedNewline + "still get broken up" +
89+
_paddedNewline + "correctly!";
90+
91+
AssertWrapBehavior(originalText, expected);
92+
}
93+
94+
[Theory]
95+
[InlineData("You can have\n\ndouble lines.")]
96+
[InlineData("You can have\r\n\r\ndouble lines.")]
97+
public void DoubleLinesArePreserved(string originalText)
98+
{
99+
var expected = "You can have" +
100+
_paddedNewline +
101+
_paddedNewline + "double lines.";
102+
103+
AssertWrapBehavior(originalText, expected);
104+
}
105+
}
106+
}

test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<ItemGroup>
1616
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" />
1717
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
18+
<PackageReference Include="FluentAssertions" Version="5.7.0" />
1819
<PackageReference Include="Moq" Version="4.10.0" />
1920
<PackageReference Include="xunit" Version="2.4.1" />
2021
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />

0 commit comments

Comments
 (0)