diff --git a/src/CommandLine/Infrastructure/StringBuilderExtensions.cs b/src/CommandLine/Infrastructure/StringBuilderExtensions.cs index 6519b66f..ae4ccbc4 100644 --- a/src/CommandLine/Infrastructure/StringBuilderExtensions.cs +++ b/src/CommandLine/Infrastructure/StringBuilderExtensions.cs @@ -113,5 +113,39 @@ public static int TrailingSpaces(this StringBuilder builder) } return c; } + + /// + /// Indicates whether the string value of a + /// starts with the input parameter. Returns false if either + /// the StringBuilder or input string is null or empty. + /// + /// The to test. + /// The to look for. + /// + public static bool SafeStartsWith(this StringBuilder builder, string s) + { + if (string.IsNullOrEmpty(s)) + return false; + + return builder?.Length >= s.Length + && builder.ToString(0, s.Length) == s; + } + + /// + /// Indicates whether the string value of a + /// ends with the input parameter. Returns false if either + /// the StringBuilder or input string is null or empty. + /// + /// The to test. + /// The to look for. + /// + public static bool SafeEndsWith(this StringBuilder builder, string s) + { + if (string.IsNullOrEmpty(s)) + return false; + + return builder?.Length >= s.Length + && builder.ToString(builder.Length - s.Length, s.Length) == s; + } } -} \ No newline at end of file +} diff --git a/src/CommandLine/Text/HelpText.cs b/src/CommandLine/Text/HelpText.cs index b23bb804..2112a904 100644 --- a/src/CommandLine/Text/HelpText.cs +++ b/src/CommandLine/Text/HelpText.cs @@ -109,6 +109,7 @@ ComparableOption ToComparableOption(Specification spec, int index) private bool addEnumValuesToHelpText; private bool autoHelp; private bool autoVersion; + private bool addNewLineBetweenHelpSections; /// /// Initializes a new instance of the class. @@ -258,6 +259,15 @@ public bool AdditionalNewLineAfterOption set { additionalNewLineAfterOption = value; } } + /// + /// Gets or sets a value indicating whether to add newlines between help sections. + /// + public bool AddNewLineBetweenHelpSections + { + get { return addNewLineBetweenHelpSections; } + set { addNewLineBetweenHelpSections = value; } + } + /// /// Gets or sets a value indicating whether to add the values of an enum after the description of the specification. /// @@ -352,7 +362,11 @@ public static HelpText AutoBuild( { var heading = auto.SentenceBuilder.UsageHeadingText(); if (heading.Length > 0) + { + if (auto.AddNewLineBetweenHelpSections) + heading = Environment.NewLine + heading; auto.AddPreOptionsLine(heading); + } } usageAttr.Do( @@ -707,19 +721,40 @@ public static IEnumerable RenderUsageTextAsLines(ParserResult pars public override string ToString() { const int ExtraLength = 10; - return - new StringBuilder( - heading.SafeLength() + copyright.SafeLength() + preOptionsHelp.SafeLength() + - optionsHelp.SafeLength() + ExtraLength).Append(heading) - .AppendWhen(!string.IsNullOrEmpty(copyright), Environment.NewLine, copyright) - .AppendWhen(preOptionsHelp.Length > 0, Environment.NewLine, preOptionsHelp.ToString()) - .AppendWhen( - optionsHelp != null && optionsHelp.Length > 0, + + var sbLength = heading.SafeLength() + copyright.SafeLength() + preOptionsHelp.SafeLength() + + optionsHelp.SafeLength() + postOptionsHelp.SafeLength() + ExtraLength; + var result = new StringBuilder(sbLength); + + result.Append(heading) + .AppendWhen(!string.IsNullOrEmpty(copyright), + Environment.NewLine, + copyright) + .AppendWhen(preOptionsHelp.SafeLength() > 0, + NewLineIfNeededBefore(preOptionsHelp), + Environment.NewLine, + preOptionsHelp.ToString()) + .AppendWhen(optionsHelp.SafeLength() > 0, Environment.NewLine, Environment.NewLine, optionsHelp.SafeToString()) - .AppendWhen(postOptionsHelp.Length > 0, Environment.NewLine, postOptionsHelp.ToString()) - .ToString(); + .AppendWhen(postOptionsHelp.SafeLength() > 0, + NewLineIfNeededBefore(postOptionsHelp), + Environment.NewLine, + postOptionsHelp.ToString()); + + string NewLineIfNeededBefore(StringBuilder sb) + { + if (AddNewLineBetweenHelpSections + && result.Length > 0 + && !result.SafeEndsWith(Environment.NewLine) + && !sb.SafeStartsWith(Environment.NewLine)) + return Environment.NewLine; + else + return null; + } + + return result.ToString(); } internal static void AddLine(StringBuilder builder, string value, int maximumLength) diff --git a/tests/CommandLine.Tests/Unit/StringBuilderExtensionsTests.cs b/tests/CommandLine.Tests/Unit/StringBuilderExtensionsTests.cs new file mode 100644 index 00000000..8fe73a60 --- /dev/null +++ b/tests/CommandLine.Tests/Unit/StringBuilderExtensionsTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using FluentAssertions; +using CommandLine.Infrastructure; + +namespace CommandLine.Tests.Unit +{ + public class StringBuilderExtensionsTests + { + private static StringBuilder _sb = new StringBuilder("test string"); + private static StringBuilder _emptySb = new StringBuilder(); + private static StringBuilder _nullSb = null; + + public static IEnumerable GoodStartsWithData => new [] + { + new object[] { "t" }, + new object[] { "te" }, + new object[] { "test " }, + new object[] { "test string" } + }; + + public static IEnumerable BadTestData => new [] + { + new object[] { null }, + new object[] { "" }, + new object[] { "xyz" }, + new object[] { "some long test string" } + }; + + public static IEnumerable GoodEndsWithData => new[] + { + new object[] { "g" }, + new object[] { "ng" }, + new object[] { " string" }, + new object[] { "test string" } + }; + + + + [Theory] + [MemberData(nameof(GoodStartsWithData))] + [MemberData(nameof(BadTestData))] + public void StartsWith_null_builder_returns_false(string input) + { + _nullSb.SafeStartsWith(input).Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(GoodStartsWithData))] + [MemberData(nameof(BadTestData))] + public void StartsWith_empty_builder_returns_false(string input) + { + _emptySb.SafeStartsWith(input).Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(GoodStartsWithData))] + public void StartsWith_good_data_returns_true(string input) + { + _sb.SafeStartsWith(input).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(BadTestData))] + public void StartsWith_bad_data_returns_false(string input) + { + _sb.SafeStartsWith(input).Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(GoodEndsWithData))] + [MemberData(nameof(BadTestData))] + public void EndsWith_null_builder_returns_false(string input) + { + _nullSb.SafeEndsWith(input).Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(GoodEndsWithData))] + [MemberData(nameof(BadTestData))] + public void EndsWith_empty_builder_returns_false(string input) + { + _emptySb.SafeEndsWith(input).Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(GoodEndsWithData))] + public void EndsWith_good_data_returns_true(string input) + { + _sb.SafeEndsWith(input).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(BadTestData))] + public void EndsWith_bad_data_returns_false(string input) + { + _sb.SafeEndsWith(input).Should().BeFalse(); + } + } +} diff --git a/tests/CommandLine.Tests/Unit/Text/HelpTextTests.cs b/tests/CommandLine.Tests/Unit/Text/HelpTextTests.cs index 1dd8d45f..32e7ee90 100644 --- a/tests/CommandLine.Tests/Unit/Text/HelpTextTests.cs +++ b/tests/CommandLine.Tests/Unit/Text/HelpTextTests.cs @@ -28,53 +28,73 @@ public void Create_empty_instance() string.Empty.Should().BeEquivalentTo(new HelpText().ToString()); } - [Fact] - public void Create_instance_without_options() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Create_instance_without_options(bool newlineBetweenSections) { // Fixture setup // Exercize system var sut = - new HelpText(new HeadingInfo("Unit-tests", "2.0"), new CopyrightInfo(true, "Author", 2005, 2013)) - .AddPreOptionsLine("pre-options line 1") + new HelpText(new HeadingInfo("Unit-tests", "2.0"), new CopyrightInfo(true, "Author", 2005, 2013)); + sut.AddNewLineBetweenHelpSections = newlineBetweenSections; + sut.AddPreOptionsLine("pre-options line 1") .AddPreOptionsLine("pre-options line 2") .AddPostOptionsLine("post-options line 1") .AddPostOptionsLine("post-options line 2"); // Verify outcome - var lines = sut.ToString().ToNotEmptyLines(); + var expected = new List() + { + "Unit-tests 2.0", + "Copyright (C) 2005 - 2013 Author", + "pre-options line 1", + "pre-options line 2", + "post-options line 1", + "post-options line 2" + }; + + if (newlineBetweenSections) + { + expected.Insert(2, ""); + expected.Insert(5, ""); + } - lines[0].Should().BeEquivalentTo("Unit-tests 2.0"); - lines[1].Should().BeEquivalentTo("Copyright (C) 2005 - 2013 Author"); - lines[2].Should().BeEquivalentTo("pre-options line 1"); - lines[3].Should().BeEquivalentTo("pre-options line 2"); - lines[4].Should().BeEquivalentTo("post-options line 1"); - lines[5].Should().BeEquivalentTo("post-options line 2"); - // Teardown + var lines = sut.ToString().ToLines(); + lines.Should().StartWith(expected); } - [Fact] - public void Create_instance_with_options() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Create_instance_with_options(bool newlineBetweenSections) { // Fixture setup // Exercize system - var sut = new HelpText { AddDashesToOption = true } + var sut = new HelpText { AddDashesToOption = true, AddNewLineBetweenHelpSections = newlineBetweenSections } .AddPreOptionsLine("pre-options") .AddOptions(new NotParsed(TypeInfo.Create(typeof(Simple_Options)), Enumerable.Empty())) .AddPostOptionsLine("post-options"); // Verify outcome - - var lines = sut.ToString().ToNotEmptyLines().TrimStringArray(); - lines[0].Should().BeEquivalentTo("pre-options"); - lines[1].Should().BeEquivalentTo("--stringvalue Define a string value here."); - lines[2].Should().BeEquivalentTo("-s, --shortandlong Example with both short and long name."); - lines[3].Should().BeEquivalentTo("-i Define a int sequence here."); - lines[4].Should().BeEquivalentTo("-x Define a boolean or switch value here."); - lines[5].Should().BeEquivalentTo("--help Display this help screen."); - lines[6].Should().BeEquivalentTo("--version Display version information."); - lines[7].Should().BeEquivalentTo("value pos. 0 Define a long value here."); - lines[8].Should().BeEquivalentTo("post-options"); - // Teardown + var expected = new [] + { + "", + "pre-options", + "", + "--stringvalue Define a string value here.", + "-s, --shortandlong Example with both short and long name.", + "-i Define a int sequence here.", + "-x Define a boolean or switch value here.", + "--help Display this help screen.", + "--version Display version information.", + "value pos. 0 Define a long value here.", + "", + "post-options" + }; + + var lines = sut.ToString().ToLines().TrimStringArray(); + lines.Should().StartWith(expected); } [Fact] @@ -320,17 +340,23 @@ public void Invoke_AutoBuild_for_Options_returns_appropriate_formatted_text() var helpText = HelpText.AutoBuild(fakeResult); // Verify outcome - var lines = helpText.ToString().ToNotEmptyLines().TrimStringArray(); + var lines = helpText.ToString().ToLines().TrimStringArray(); lines[0].Should().Be(HeadingInfo.Default.ToString()); - lines[1].Should().Be(CopyrightInfo.Default.ToString()); - lines[2].Should().BeEquivalentTo("ERROR(S):"); - lines[3].Should().BeEquivalentTo("Token 'badtoken' is not recognized."); - lines[4].Should().BeEquivalentTo("A sequence option 'i' is defined with fewer or more items than required."); - lines[5].Should().BeEquivalentTo("--stringvalue Define a string value here."); - lines[6].Should().BeEquivalentTo("-s, --shortandlong Example with both short and long name."); - lines[7].Should().BeEquivalentTo("-i Define a int sequence here."); - lines[8].Should().BeEquivalentTo("-x Define a boolean or switch value here."); - lines[9].Should().BeEquivalentTo("--help Display this help screen."); + lines[1].Should().Be(CopyrightInfo.Default.ToString()); + lines[2].Should().BeEmpty(); + lines[3].Should().BeEquivalentTo("ERROR(S):"); + lines[4].Should().BeEquivalentTo("Token 'badtoken' is not recognized."); + lines[5].Should().BeEquivalentTo("A sequence option 'i' is defined with fewer or more items than required."); + lines[6].Should().BeEmpty(); + lines[7].Should().BeEquivalentTo("--stringvalue Define a string value here."); + lines[8].Should().BeEmpty(); + lines[9].Should().BeEquivalentTo("-s, --shortandlong Example with both short and long name."); + lines[10].Should().BeEmpty(); + lines[11].Should().BeEquivalentTo("-i Define a int sequence here."); + lines[12].Should().BeEmpty(); + lines[13].Should().BeEquivalentTo("-x Define a boolean or switch value here."); + lines[14].Should().BeEmpty(); + lines[15].Should().BeEquivalentTo("--help Display this help screen."); // Teardown } @@ -349,15 +375,19 @@ public void Invoke_AutoBuild_for_Verbs_with_specific_verb_returns_appropriate_fo var helpText = HelpText.AutoBuild(fakeResult); // Verify outcome - var lines = helpText.ToString().ToNotEmptyLines().TrimStringArray(); + var lines = helpText.ToString().ToLines().TrimStringArray(); lines[0].Should().Be(HeadingInfo.Default.ToString()); - lines[1].Should().Be(CopyrightInfo.Default.ToString()); - lines[2].Should().BeEquivalentTo("-p, --patch Use the interactive patch selection interface to chose which"); - lines[3].Should().BeEquivalentTo("changes to commit."); - lines[4].Should().BeEquivalentTo("--amend Used to amend the tip of the current branch."); - lines[5].Should().BeEquivalentTo("-m, --message Use the given message as the commit message."); - lines[6].Should().BeEquivalentTo("--help Display this help screen."); + lines[1].Should().Be(CopyrightInfo.Default.ToString()); + lines[2].Should().BeEmpty(); + lines[3].Should().BeEquivalentTo("-p, --patch Use the interactive patch selection interface to chose which"); + lines[4].Should().BeEquivalentTo("changes to commit."); + lines[5].Should().BeEmpty(); + lines[6].Should().BeEquivalentTo("--amend Used to amend the tip of the current branch."); + lines[7].Should().BeEmpty(); + lines[8].Should().BeEquivalentTo("-m, --message Use the given message as the commit message."); + lines[9].Should().BeEmpty(); + lines[10].Should().BeEquivalentTo("--help Display this help screen."); // Teardown } @@ -418,23 +448,26 @@ public void Create_instance_with_options_and_values() { // Fixture setup // Exercize system - var sut = new HelpText { AddDashesToOption = true } + var sut = new HelpText { AddDashesToOption = true, AdditionalNewLineAfterOption = false } .AddPreOptionsLine("pre-options") .AddOptions(new NotParsed(TypeInfo.Create(typeof(Options_With_HelpText_And_MetaValue)), Enumerable.Empty())) .AddPostOptionsLine("post-options"); // Verify outcome - var lines = sut.ToString().ToNotEmptyLines().TrimStringArray(); - lines[0].Should().BeEquivalentTo("pre-options"); - lines[1].Should().BeEquivalentTo("--stringvalue=STR Define a string value here."); - lines[2].Should().BeEquivalentTo("-i INTSEQ Define a int sequence here."); - lines[3].Should().BeEquivalentTo("-x Define a boolean or switch value here."); - lines[4].Should().BeEquivalentTo("--help Display this help screen."); - lines[5].Should().BeEquivalentTo("--version Display version information."); - lines[6].Should().BeEquivalentTo("number (pos. 0) NUM Define a long value here."); - lines[7].Should().BeEquivalentTo("paintcolor (pos. 1) COLOR Define a color value here."); - lines[8].Should().BeEquivalentTo("post-options", lines[8]); + var lines = sut.ToString().ToLines().TrimStringArray(); + lines[0].Should().BeEmpty(); + lines[1].Should().BeEquivalentTo("pre-options"); + lines[2].Should().BeEmpty(); + lines[3].Should().BeEquivalentTo("--stringvalue=STR Define a string value here."); + lines[4].Should().BeEquivalentTo("-i INTSEQ Define a int sequence here."); + lines[5].Should().BeEquivalentTo("-x Define a boolean or switch value here."); + lines[6].Should().BeEquivalentTo("--help Display this help screen."); + lines[7].Should().BeEquivalentTo("--version Display version information."); + lines[8].Should().BeEquivalentTo("number (pos. 0) NUM Define a long value here."); + lines[9].Should().BeEquivalentTo("paintcolor (pos. 1) COLOR Define a color value here."); + lines[10].Should().BeEmpty(); + lines[11].Should().BeEquivalentTo("post-options", lines[11]); // Teardown } @@ -466,8 +499,10 @@ public static void RenderUsageText_returns_properly_formatted_text() lines[10].Should().BeEquivalentTo(" mono testapp.exe value"); } - [Fact] - public void Invoke_AutoBuild_for_Options_with_Usage_returns_appropriate_formatted_text() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Invoke_AutoBuild_for_Options_with_Usage_returns_appropriate_formatted_text(bool newlineBetweenSections) { // Fixture setup var fakeResult = new NotParsed( @@ -478,38 +513,112 @@ public void Invoke_AutoBuild_for_Options_with_Usage_returns_appropriate_formatte }); // Exercize system - var helpText = HelpText.AutoBuild(fakeResult); + var helpText = HelpText.AutoBuild(fakeResult, + h => + { + h.AddNewLineBetweenHelpSections = newlineBetweenSections; + return HelpText.DefaultParsingErrorsHandler(fakeResult, h); + }, + e => e + ); + + // Verify outcome + var expected = new List() + { + HeadingInfo.Default.ToString(), + CopyrightInfo.Default.ToString(), + "", + "ERROR(S):", + "Token 'badtoken' is not recognized.", + "USAGE:", + "Normal scenario:", + "mono testapp.exe --input file.bin --output out.bin", + "Logging warnings:", + "mono testapp.exe -w --input file.bin", + "Logging errors:", + "mono testapp.exe -e --input file.bin", + "mono testapp.exe --errs --input=file.bin", + "List:", + "mono testapp.exe -l 1,2", + "Value:", + "mono testapp.exe value", + "", + "-i, --input Set input file.", + "", + "-i, --output Set output file.", + "", + "--verbose Set verbosity level.", + "", + "-w, --warns Log warnings.", + "", + "-e, --errs Log errors.", + "", + "-l List.", + "", + "--help Display this help screen.", + "", + "--version Display version information.", + "", + "value pos. 0 Value." + }; + + if (newlineBetweenSections) + expected.Insert(5, ""); + + var text = helpText.ToString(); + var lines = text.ToLines().TrimStringArray(); + + lines.Should().StartWith(expected); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void AutoBuild_with_errors_and_preoptions_renders_correctly(bool startWithNewline, bool newlineBetweenSections) + { + // Fixture setup + var fakeResult = new NotParsed( + TypeInfo.Create(typeof(Simple_Options_Without_HelpText)), + new Error[] + { + new BadFormatTokenError("badtoken") + }); + + // Exercize system + var helpText = HelpText.AutoBuild(fakeResult, + h => + { + h.AddNewLineBetweenHelpSections = newlineBetweenSections; + h.AddPreOptionsLine((startWithNewline ? Environment.NewLine : null) + "pre-options"); + return HelpText.DefaultParsingErrorsHandler(fakeResult, h); + }, + e => e + ); // Verify outcome + var expected = new List() + { + HeadingInfo.Default.ToString(), + CopyrightInfo.Default.ToString(), + "pre-options", + "", + "ERROR(S):", + "Token 'badtoken' is not recognized.", + "", + "-v, --verbose", + "", + "--input-file" + }; + + if (newlineBetweenSections || startWithNewline) + expected.Insert(2, ""); + var text = helpText.ToString(); - var lines = text.ToNotEmptyLines().TrimStringArray(); - lines[0].Should().Be(HeadingInfo.Default.ToString()); - lines[1].Should().Be(CopyrightInfo.Default.ToString()); - lines[2].Should().BeEquivalentTo("ERROR(S):"); - lines[3].Should().BeEquivalentTo("Token 'badtoken' is not recognized."); - lines[4].Should().BeEquivalentTo("USAGE:"); - lines[5].Should().BeEquivalentTo("Normal scenario:"); - lines[6].Should().BeEquivalentTo("mono testapp.exe --input file.bin --output out.bin"); - lines[7].Should().BeEquivalentTo("Logging warnings:"); - lines[8].Should().BeEquivalentTo("mono testapp.exe -w --input file.bin"); - lines[9].Should().BeEquivalentTo("Logging errors:"); - lines[10].Should().BeEquivalentTo("mono testapp.exe -e --input file.bin"); - lines[11].Should().BeEquivalentTo("mono testapp.exe --errs --input=file.bin"); - lines[12].Should().BeEquivalentTo("List:"); - lines[13].Should().BeEquivalentTo("mono testapp.exe -l 1,2"); - lines[14].Should().BeEquivalentTo("Value:"); - lines[15].Should().BeEquivalentTo("mono testapp.exe value"); - lines[16].Should().BeEquivalentTo("-i, --input Set input file."); - lines[17].Should().BeEquivalentTo("-i, --output Set output file."); - lines[18].Should().BeEquivalentTo("--verbose Set verbosity level."); - lines[19].Should().BeEquivalentTo("-w, --warns Log warnings."); - lines[20].Should().BeEquivalentTo("-e, --errs Log errors."); - lines[21].Should().BeEquivalentTo("-l List."); - lines[22].Should().BeEquivalentTo("--help Display this help screen."); - lines[23].Should().BeEquivalentTo("--version Display version information."); - lines[24].Should().BeEquivalentTo("value pos. 0 Value."); + var lines = text.ToLines().TrimStringArray(); - // Teardown + lines.Should().StartWith(expected); } [Fact]