diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 8c370ad..f899215 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -19,7 +19,9 @@ jobs: dotnet-version: 5.0.x - name: Pack - run: dotnet pack CliFx --configuration Release + run: | + dotnet nuget locals all --clear + dotnet pack CliFx --configuration Release - name: Deploy run: dotnet nuget push CliFx/bin/Release/*.nupkg -s nuget.org -k ${{ secrets.NUGET_TOKEN }} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9559548..1ead049 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,7 +20,9 @@ jobs: dotnet-version: 5.0.x - name: Build & test - run: dotnet test --configuration Release --logger GitHubActions + run: | + dotnet nuget locals all --clear + dotnet test --configuration Release --logger GitHubActions - name: Upload coverage uses: codecov/codecov-action@v1.0.5 diff --git a/CliFx.Analyzers.Tests/AnalyzerTestCase.cs b/CliFx.Analyzers.Tests/AnalyzerTestCase.cs deleted file mode 100644 index fa15385..0000000 --- a/CliFx.Analyzers.Tests/AnalyzerTestCase.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis; - -namespace CliFx.Analyzers.Tests -{ - public class AnalyzerTestCase - { - public string Name { get; } - - public IReadOnlyList TestedDiagnostics { get; } - - public IReadOnlyList SourceCodes { get; } - - public AnalyzerTestCase( - string name, - IReadOnlyList testedDiagnostics, - IReadOnlyList sourceCodes) - { - Name = name; - TestedDiagnostics = testedDiagnostics; - SourceCodes = sourceCodes; - } - - public AnalyzerTestCase( - string name, - IReadOnlyList testedDiagnostics, - string sourceCode) - : this(name, testedDiagnostics, new[] {sourceCode}) - { - } - - public AnalyzerTestCase( - string name, - DiagnosticDescriptor testedDiagnostic, - string sourceCode) - : this(name, new[] {testedDiagnostic}, sourceCode) - { - } - - public override string ToString() => $"{Name} [{string.Join(", ", TestedDiagnostics.Select(d => d.Id))}]"; - } -} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj b/CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj index 46bf5c7..12fb17e 100644 --- a/CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj +++ b/CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj @@ -9,14 +9,17 @@ - - + + + + + - - + + - + diff --git a/CliFx.Analyzers.Tests/CommandMustBeAnnotatedAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/CommandMustBeAnnotatedAnalyzerSpecs.cs new file mode 100644 index 0000000..da89e57 --- /dev/null +++ b/CliFx.Analyzers.Tests/CommandMustBeAnnotatedAnalyzerSpecs.cs @@ -0,0 +1,72 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class CommandMustBeAnnotatedAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new CommandMustBeAnnotatedAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_command_is_not_annotated_with_the_command_attribute() + { + // Arrange + // language=cs + const string code = @" +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_command_is_annotated_with_the_command_attribute() + { + // Arrange + // language=cs + const string code = @" +[Command] +public abstract class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_command_is_implemented_as_an_abstract_class() + { + // Arrange + // language=cs + const string code = @" +public abstract class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command() + { + // Arrange + // language=cs + const string code = @" +public class Foo +{ + public int Bar { get; set; } = 5; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/CommandMustImplementInterfaceAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/CommandMustImplementInterfaceAnalyzerSpecs.cs new file mode 100644 index 0000000..9b1abc9 --- /dev/null +++ b/CliFx.Analyzers.Tests/CommandMustImplementInterfaceAnalyzerSpecs.cs @@ -0,0 +1,58 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class CommandMustImplementInterfaceAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new CommandMustImplementInterfaceAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_command_does_not_implement_ICommand_interface() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_command_implements_ICommand_interface() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command() + { + // Arrange + // language=cs + const string code = @" +public class Foo +{ + public int Bar { get; set; } = 5; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs b/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs deleted file mode 100644 index bfcc3e3..0000000 --- a/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs +++ /dev/null @@ -1,719 +0,0 @@ -using System.Collections.Generic; -using CliFx.Analyzers.Tests.Internal; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace CliFx.Analyzers.Tests -{ - public class CommandSchemaAnalyzerTests - { - private static DiagnosticAnalyzer Analyzer { get; } = new CommandSchemaAnalyzer(); - - public static IEnumerable GetValidCases() - { - yield return new object[] - { - new AnalyzerTestCase( - "Non-command type", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public class Foo -{ - public int Bar { get; set; } = 5; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Command implements interface and has attribute", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Command doesn't have an attribute but is an abstract type", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public abstract class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameters with unique order", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(13)] - public string ParamA { get; set; } - - [CommandParameter(15)] - public string ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameters with unique names", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(13, Name = ""foo"")] - public string ParamA { get; set; } - - [CommandParameter(15, Name = ""bar"")] - public string ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Single non-scalar parameter", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(1)] - public string ParamA { get; set; } - - [CommandParameter(2)] - public HashSet ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Non-scalar parameter is last in order", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(1)] - public string ParamA { get; set; } - - [CommandParameter(2)] - public IReadOnlyList ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameter with valid converter", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public class MyConverter : ArgumentValueConverter -{ - public string ConvertFrom(string value) => value; -} - -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(0, Converter = typeof(MyConverter))] - public string Param { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameter with valid validator", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public class MyValidator : ArgumentValueValidator -{ - public ValidationResult Validate(string value) => ValidationResult.Ok(); -} - -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(0, Validators = new[] {typeof(MyValidator)})] - public string Param { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with a proper name", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""foo"")] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with a proper name and short name", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""foo"", 'f')] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with unique names", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""foo"")] - public string OptionA { get; set; } - - [CommandOption(""bar"")] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with unique short names", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption('f')] - public string OptionA { get; set; } - - [CommandOption('x')] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with unique environment variable names", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption('a', EnvironmentVariableName = ""env_var_a"")] - public string OptionA { get; set; } - - [CommandOption('b', EnvironmentVariableName = ""env_var_b"")] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with valid converter", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public class MyConverter : ArgumentValueConverter -{ - public string ConvertFrom(string value) => value; -} - -[Command] -public class MyCommand : ICommand -{ - [CommandOption('o', Converter = typeof(MyConverter))] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with valid validator", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -public class MyValidator : ArgumentValueValidator -{ - public ValidationResult Validate(string value) => ValidationResult.Ok(); -} - -[Command] -public class MyCommand : ICommand -{ - [CommandOption('o', Validators = new[] {typeof(MyValidator)})] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - } - - public static IEnumerable GetInvalidCases() - { - yield return new object[] - { - new AnalyzerTestCase( - "Command is missing the attribute", - DiagnosticDescriptors.CliFx0002, - - // language=cs - @" -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Command doesn't implement the interface", - DiagnosticDescriptors.CliFx0001, - - // language=cs - @" -[Command] -public class MyCommand -{ - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameters with duplicate order", - DiagnosticDescriptors.CliFx0021, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(13)] - public string ParamA { get; set; } - - [CommandParameter(13)] - public string ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameters with duplicate names", - DiagnosticDescriptors.CliFx0022, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(13, Name = ""foo"")] - public string ParamA { get; set; } - - [CommandParameter(15, Name = ""foo"")] - public string ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Multiple non-scalar parameters", - DiagnosticDescriptors.CliFx0023, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(1)] - public IReadOnlyList ParamA { get; set; } - - [CommandParameter(2)] - public HashSet ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Non-last non-scalar parameter", - DiagnosticDescriptors.CliFx0024, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(1)] - public IReadOnlyList ParamA { get; set; } - - [CommandParameter(2)] - public string ParamB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameter with invalid converter", - DiagnosticDescriptors.CliFx0025, - - // language=cs - @" -public class MyConverter -{ - public object ConvertFrom(string value) => value; -} - -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(0, Converter = typeof(MyConverter))] - public string Param { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Parameter with invalid validator", - DiagnosticDescriptors.CliFx0026, - - // language=cs - @" -public class MyValidator -{ - public ValidationResult Validate(string value) => ValidationResult.Ok(); -} - -[Command] -public class MyCommand : ICommand -{ - [CommandParameter(0, Validators = new[] {typeof(MyValidator)})] - public string Param { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with an empty name", - DiagnosticDescriptors.CliFx0041, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption("""")] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with a name which is too short", - DiagnosticDescriptors.CliFx0042, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""a"")] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with duplicate names", - DiagnosticDescriptors.CliFx0043, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""foo"")] - public string OptionA { get; set; } - - [CommandOption(""foo"")] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with duplicate short names", - DiagnosticDescriptors.CliFx0044, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption('f')] - public string OptionA { get; set; } - - [CommandOption('f')] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Options with duplicate environment variable names", - DiagnosticDescriptors.CliFx0045, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption('a', EnvironmentVariableName = ""env_var"")] - public string OptionA { get; set; } - - [CommandOption('b', EnvironmentVariableName = ""env_var"")] - public string OptionB { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with invalid converter", - DiagnosticDescriptors.CliFx0046, - - // language=cs - @" -public class MyConverter -{ - public object ConvertFrom(string value) => value; -} - -[Command] -public class MyCommand : ICommand -{ - [CommandOption('o', Converter = typeof(MyConverter))] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with invalid validator", - DiagnosticDescriptors.CliFx0047, - - // language=cs - @" -public class MyValidator -{ - public ValidationResult Validate(string value) => ValidationResult.Ok(); -} - -[Command] -public class MyCommand : ICommand -{ - [CommandOption('o', Validators = new[] {typeof(MyValidator)})] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with a name that doesn't start with a letter character", - DiagnosticDescriptors.CliFx0048, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption(""0foo"")] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Option with a short name that isn't a letter character", - DiagnosticDescriptors.CliFx0049, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - [CommandOption('0')] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - } - - [Theory] - [MemberData(nameof(GetValidCases))] - public void Valid(AnalyzerTestCase testCase) => - Analyzer.Should().NotProduceDiagnostics(testCase); - - [Theory] - [MemberData(nameof(GetInvalidCases))] - public void Invalid(AnalyzerTestCase testCase) => - Analyzer.Should().ProduceDiagnostics(testCase); - } -} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ConsoleUsageAnalyzerTests.cs b/CliFx.Analyzers.Tests/ConsoleUsageAnalyzerTests.cs deleted file mode 100644 index 6ebc953..0000000 --- a/CliFx.Analyzers.Tests/ConsoleUsageAnalyzerTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Collections.Generic; -using CliFx.Analyzers.Tests.Internal; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace CliFx.Analyzers.Tests -{ - public class ConsoleUsageAnalyzerTests - { - private static DiagnosticAnalyzer Analyzer { get; } = new ConsoleUsageAnalyzer(); - - public static IEnumerable GetValidCases() - { - yield return new object[] - { - new AnalyzerTestCase( - "Using console abstraction", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine(""Hello world""); - return default; - } -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Console abstraction is not available in scope", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public void SomeOtherMethod() => Console.WriteLine(""Test""); - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - } - - public static IEnumerable GetInvalidCases() - { - yield return new object[] - { - new AnalyzerTestCase( - "Not using available console abstraction in the ExecuteAsync method", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) - { - Console.WriteLine(""Hello world""); - return default; - } -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Not using available console abstraction in the ExecuteAsync method when writing stderr", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) - { - Console.Error.WriteLine(""Hello world""); - return default; - } -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Not using available console abstraction while referencing System.Console by full name", - Analyzer.SupportedDiagnostics, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public ValueTask ExecuteAsync(IConsole console) - { - System.Console.Error.WriteLine(""Hello world""); - return default; - } -}" - ) - }; - - yield return new object[] - { - new AnalyzerTestCase( - "Not using available console abstraction in another method", - DiagnosticDescriptors.CliFx0100, - - // language=cs - @" -[Command] -public class MyCommand : ICommand -{ - public void SomeOtherMethod(IConsole console) => Console.WriteLine(""Test""); - - public ValueTask ExecuteAsync(IConsole console) => default; -}" - ) - }; - } - - [Theory] - [MemberData(nameof(GetValidCases))] - public void Valid(AnalyzerTestCase testCase) => - Analyzer.Should().NotProduceDiagnostics(testCase); - - [Theory] - [MemberData(nameof(GetInvalidCases))] - public void Invalid(AnalyzerTestCase testCase) => - Analyzer.Should().ProduceDiagnostics(testCase); - } -} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/GeneralSpecs.cs b/CliFx.Analyzers.Tests/GeneralSpecs.cs new file mode 100644 index 0000000..7732ee9 --- /dev/null +++ b/CliFx.Analyzers.Tests/GeneralSpecs.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using FluentAssertions; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class GeneralSpecs + { + [Fact] + public void All_analyzers_have_unique_diagnostic_IDs() + { + // Arrange + var analyzers = typeof(AnalyzerBase) + .Assembly + .GetTypes() + .Where(t => !t.IsAbstract && t.IsAssignableTo(typeof(DiagnosticAnalyzer))) + .Select(t => (DiagnosticAnalyzer) Activator.CreateInstance(t)!) + .ToArray(); + + // Act + var diagnosticIds = analyzers + .SelectMany(a => a.SupportedDiagnostics.Select(d => d.Id)) + .ToArray(); + + // Assert + diagnosticIds.Should().OnlyHaveUniqueItems(); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs b/CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs deleted file mode 100644 index b7d97cc..0000000 --- a/CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions.Execution; -using FluentAssertions.Primitives; -using Gu.Roslyn.Asserts; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace CliFx.Analyzers.Tests.Internal -{ - internal partial class AnalyzerAssertions : ReferenceTypeAssertions - { - protected override string Identifier { get; } = "analyzer"; - - public AnalyzerAssertions(DiagnosticAnalyzer analyzer) - : base(analyzer) - { - } - - public void ProduceDiagnostics( - IReadOnlyList diagnostics, - IReadOnlyList sourceCodes) - { - var producedDiagnostics = GetProducedDiagnostics(Subject, sourceCodes); - - var expectedIds = diagnostics.Select(d => d.Id).Distinct().OrderBy(d => d).ToArray(); - var producedIds = producedDiagnostics.Select(d => d.Id).Distinct().OrderBy(d => d).ToArray(); - - var result = expectedIds.Intersect(producedIds).Count() == expectedIds.Length; - - Execute.Assertion.ForCondition(result).FailWith($@" -Expected and produced diagnostics do not match. - -Expected: {string.Join(", ", expectedIds)} -Produced: {(producedIds.Any() ? string.Join(", ", producedIds) : "")} -".Trim()); - } - - public void ProduceDiagnostics(AnalyzerTestCase testCase) => - ProduceDiagnostics(testCase.TestedDiagnostics, testCase.SourceCodes); - - public void NotProduceDiagnostics( - IReadOnlyList diagnostics, - IReadOnlyList sourceCodes) - { - var producedDiagnostics = GetProducedDiagnostics(Subject, sourceCodes); - - var expectedIds = diagnostics.Select(d => d.Id).Distinct().OrderBy(d => d).ToArray(); - var producedIds = producedDiagnostics.Select(d => d.Id).Distinct().OrderBy(d => d).ToArray(); - - var result = !expectedIds.Intersect(producedIds).Any(); - - Execute.Assertion.ForCondition(result).FailWith($@" -Expected no produced diagnostics. - -Produced: {string.Join(", ", producedIds)} -".Trim()); - } - - public void NotProduceDiagnostics(AnalyzerTestCase testCase) => - NotProduceDiagnostics(testCase.TestedDiagnostics, testCase.SourceCodes); - } - - internal partial class AnalyzerAssertions - { - private static IReadOnlyList DefaultMetadataReferences { get; } = - MetadataReferences.Transitive(typeof(CliApplication).Assembly).ToArray(); - - private static string WrapCodeWithUsingDirectives(string code) - { - var usingDirectives = new[] - { - "using System;", - "using System.Collections.Generic;", - "using System.Threading.Tasks;", - "using CliFx;", - "using CliFx.Attributes;", - "using CliFx.Exceptions;", - "using CliFx.Utilities;" - }; - - return - string.Join(Environment.NewLine, usingDirectives) + - Environment.NewLine + - code; - } - - private static IReadOnlyList GetProducedDiagnostics( - DiagnosticAnalyzer analyzer, - IReadOnlyList sourceCodes) - { - var compilationOptions = new CSharpCompilationOptions(OutputKind.ConsoleApplication); - var wrappedSourceCodes = sourceCodes.Select(WrapCodeWithUsingDirectives).ToArray(); - - return Analyze.GetDiagnostics(analyzer, wrappedSourceCodes, compilationOptions, DefaultMetadataReferences) - .SelectMany(d => d) - .ToArray(); - } - } - - internal static class AnalyzerAssertionsExtensions - { - public static AnalyzerAssertions Should(this DiagnosticAnalyzer analyzer) => new(analyzer); - } -} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustBeInsideCommandAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustBeInsideCommandAnalyzerSpecs.cs new file mode 100644 index 0000000..e3f78ed --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustBeInsideCommandAnalyzerSpecs.cs @@ -0,0 +1,80 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustBeInsideCommandAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustBeInsideCommandAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_is_inside_a_class_that_is_not_a_command() + { + // Arrange + // language=cs + const string code = @" +public class MyClass +{ + [CommandOption(""foo"")] + public string Foo { get; set; } +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_is_inside_a_command() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_is_inside_an_abstract_class() + { + // Arrange + // language=cs + const string code = @" +public abstract class MyCommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveNameOrShortNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveNameOrShortNameAnalyzerSpecs.cs new file mode 100644 index 0000000..56a8377 --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveNameOrShortNameAnalyzerSpecs.cs @@ -0,0 +1,86 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveNameOrShortNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveNameOrShortNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_does_not_have_a_name_or_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(null)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveUniqueNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveUniqueNameAnalyzerSpecs.cs new file mode 100644 index 0000000..c536ef9 --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveUniqueNameAnalyzerSpecs.cs @@ -0,0 +1,92 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveUniqueNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveUniqueNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_has_the_same_name_as_another_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + [CommandOption(""foo"")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + [CommandOption(""bar"")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveUniqueShortNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveUniqueShortNameAnalyzerSpecs.cs new file mode 100644 index 0000000..018951f --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveUniqueShortNameAnalyzerSpecs.cs @@ -0,0 +1,114 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveUniqueShortNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveUniqueShortNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_has_the_same_short_name_as_another_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + [CommandOption('f')] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + [CommandOption('b')] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name_which_is_unique_only_in_casing() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + [CommandOption('F')] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveValidConverterAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveValidConverterAnalyzerSpecs.cs new file mode 100644 index 0000000..d022665 --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveValidConverterAnalyzerSpecs.cs @@ -0,0 +1,96 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveValidConverterAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidConverterAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_the_specified_option_converter_does_not_derive_from_BindingConverter() + { + // Arrange + // language=cs + const string code = @" +public class MyConverter +{ + public string Convert(string rawValue) => rawValue; +} + +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"", Converter = typeof(MyConverter))] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_the_specified_option_converter_derives_from_BindingConverter() + { + // Arrange + // language=cs + const string code = @" +public class MyConverter : BindingConverter +{ + public override string Convert(string rawValue) => rawValue; +} + +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"", Converter = typeof(MyConverter))] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_converter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveValidNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveValidNameAnalyzerSpecs.cs new file mode 100644 index 0000000..7b56cf2 --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveValidNameAnalyzerSpecs.cs @@ -0,0 +1,105 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveValidNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_has_a_name_which_is_too_short() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""f"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_reports_an_error_if_an_option_has_a_name_that_starts_with_a_non_letter_character() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""1foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveValidShortNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveValidShortNameAnalyzerSpecs.cs new file mode 100644 index 0000000..d73487f --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveValidShortNameAnalyzerSpecs.cs @@ -0,0 +1,86 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveValidShortNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidShortNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_an_option_has_a_short_name_which_is_not_a_letter_character() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('1')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/OptionMustHaveValidValidatorsAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/OptionMustHaveValidValidatorsAnalyzerSpecs.cs new file mode 100644 index 0000000..7a854a2 --- /dev/null +++ b/CliFx.Analyzers.Tests/OptionMustHaveValidValidatorsAnalyzerSpecs.cs @@ -0,0 +1,96 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class OptionMustHaveValidValidatorsAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidValidatorsAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_one_of_the_specified_option_validators_does_not_derive_from_BindingValidator() + { + // Arrange + // language=cs + const string code = @" +public class MyValidator +{ + public void Validate(string value) {} +} + +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"", Validators = new[] {typeof(MyValidator)})] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_all_specified_option_validators_derive_from_BindingValidator() + { + // Arrange + // language=cs + const string code = @" +public class MyValidator : BindingValidator +{ + public override BindingValidationError Validate(string value) => Ok(); +} + +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"", Validators = new[] {typeof(MyValidator)})] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_validators() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustBeInsideCommandAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustBeInsideCommandAnalyzerSpecs.cs new file mode 100644 index 0000000..8655a34 --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustBeInsideCommandAnalyzerSpecs.cs @@ -0,0 +1,80 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustBeInsideCommandAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustBeInsideCommandAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_parameter_is_inside_a_class_that_is_not_a_command() + { + // Arrange + // language=cs + const string code = @" +public class MyClass +{ + [CommandParameter(0)] + public string Foo { get; set; } +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_a_command() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_an_abstract_class() + { + // Arrange + // language=cs + const string code = @" +public abstract class MyCommand +{ + [CommandParameter(0)] + public string Foo { get; set; } +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustBeLastIfNonScalarAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustBeLastIfNonScalarAnalyzerSpecs.cs new file mode 100644 index 0000000..2118678 --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustBeLastIfNonScalarAnalyzerSpecs.cs @@ -0,0 +1,95 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustBeLastIfNonScalarAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustBeLastIfNonScalarAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_non_scalar_parameter_is_not_last_in_order() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string[] Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_non_scalar_parameter_is_last_in_order() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string[] Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustBeSingleIfNonScalarAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustBeSingleIfNonScalarAnalyzerSpecs.cs new file mode 100644 index 0000000..c17243b --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustBeSingleIfNonScalarAnalyzerSpecs.cs @@ -0,0 +1,95 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustBeSingleIfNonScalarAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustBeSingleIfNonScalarAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_more_than_one_non_scalar_parameters_are_defined() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string[] Foo { get; set; } + + [CommandParameter(1)] + public string[] Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_only_one_non_scalar_parameter_is_defined() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string[] Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustHaveUniqueNameAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustHaveUniqueNameAnalyzerSpecs.cs new file mode 100644 index 0000000..77a5278 --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustHaveUniqueNameAnalyzerSpecs.cs @@ -0,0 +1,73 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustHaveUniqueNameAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveUniqueNameAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_parameter_has_the_same_name_as_another_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Name = ""foo"")] + public string Foo { get; set; } + + [CommandParameter(1, Name = ""foo"")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_name() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Name = ""foo"")] + public string Foo { get; set; } + + [CommandParameter(1, Name = ""bar"")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustHaveUniqueOrderAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustHaveUniqueOrderAnalyzerSpecs.cs new file mode 100644 index 0000000..6ed24ed --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustHaveUniqueOrderAnalyzerSpecs.cs @@ -0,0 +1,73 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustHaveUniqueOrderAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveUniqueOrderAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_parameter_has_the_same_order_as_another_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(0)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_order() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustHaveValidConverterAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustHaveValidConverterAnalyzerSpecs.cs new file mode 100644 index 0000000..c3635f5 --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustHaveValidConverterAnalyzerSpecs.cs @@ -0,0 +1,96 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustHaveValidConverterAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidConverterAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_the_specified_parameter_converter_does_not_derive_from_BindingConverter() + { + // Arrange + // language=cs + const string code = @" +public class MyConverter +{ + public string Convert(string rawValue) => rawValue; +} + +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Converter = typeof(MyConverter))] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_the_specified_parameter_converter_derives_from_BindingConverter() + { + // Arrange + // language=cs + const string code = @" +public class MyConverter : BindingConverter +{ + public override string Convert(string rawValue) => rawValue; +} + +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Converter = typeof(MyConverter))] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_a_converter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/ParameterMustHaveValidValidatorsAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/ParameterMustHaveValidValidatorsAnalyzerSpecs.cs new file mode 100644 index 0000000..52b810f --- /dev/null +++ b/CliFx.Analyzers.Tests/ParameterMustHaveValidValidatorsAnalyzerSpecs.cs @@ -0,0 +1,96 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class ParameterMustHaveValidValidatorsAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidValidatorsAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_one_of_the_specified_parameter_validators_does_not_derive_from_BindingValidator() + { + // Arrange + // language=cs + const string code = @" +public class MyValidator +{ + public void Validate(string value) {} +} + +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Validators = new[] {typeof(MyValidator)})] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_all_specified_parameter_validators_derive_from_BindingValidator() + { + // Arrange + // language=cs + const string code = @" +public class MyValidator : BindingValidator +{ + public override BindingValidationError Validate(string value) => Ok(); +} + +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0, Validators = new[] {typeof(MyValidator)})] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_validators() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/SystemConsoleShouldBeAvoidedAnalyzerSpecs.cs b/CliFx.Analyzers.Tests/SystemConsoleShouldBeAvoidedAnalyzerSpecs.cs new file mode 100644 index 0000000..b241050 --- /dev/null +++ b/CliFx.Analyzers.Tests/SystemConsoleShouldBeAvoidedAnalyzerSpecs.cs @@ -0,0 +1,108 @@ +using CliFx.Analyzers.Tests.Utils; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace CliFx.Analyzers.Tests +{ + public class SystemConsoleShouldBeAvoidedAnalyzerSpecs + { + private static DiagnosticAnalyzer Analyzer { get; } = new SystemConsoleShouldBeAvoidedAnalyzer(); + + [Fact] + public void Analyzer_reports_an_error_if_a_command_calls_a_method_on_SystemConsole() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + Console.WriteLine(""Hello world""); + return default; + } +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_reports_an_error_if_a_command_accesses_a_property_on_SystemConsole() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + Console.ForegroundColor = ConsoleColor.Black; + return default; + } +}"; + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_reports_an_error_if_a_command_calls_a_method_on_a_property_of_SystemConsole() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + Console.Error.WriteLine(""Hello world""); + return default; + } +}"; + + // Act & assert + Analyzer.Should().ProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_a_command_interacts_with_the_console_through_IConsole() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Hello world""); + return default; + } +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + + [Fact] + public void Analyzer_does_not_report_an_error_if_IConsole_is_not_available_in_the_current_method() + { + // Arrange + // language=cs + const string code = @" +[Command] +public class MyCommand : ICommand +{ + public void SomeOtherMethod() => Console.WriteLine(""Test""); + + public ValueTask ExecuteAsync(IConsole console) => default; +}"; + + // Act & assert + Analyzer.Should().NotProduceDiagnostics(code); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/Utils/AnalyzerAssertions.cs b/CliFx.Analyzers.Tests/Utils/AnalyzerAssertions.cs new file mode 100644 index 0000000..7c34d6e --- /dev/null +++ b/CliFx.Analyzers.Tests/Utils/AnalyzerAssertions.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace CliFx.Analyzers.Tests.Utils +{ + internal class AnalyzerAssertions : ReferenceTypeAssertions + { + protected override string Identifier { get; } = "analyzer"; + + public AnalyzerAssertions(DiagnosticAnalyzer analyzer) + : base(analyzer) + { + } + + private Compilation Compile(string sourceCode) + { + // Get default system namespaces + var defaultSystemNamespaces = new[] + { + "System", + "System.Collections.Generic", + "System.Threading.Tasks" + }; + + // Get default CliFx namespaces + var defaultCliFxNamespaces = typeof(ICommand) + .Assembly + .GetTypes() + .Where(t => t.IsPublic) + .Select(t => t.Namespace) + .Distinct() + .ToArray(); + + // Append default imports to the source code + var sourceCodeWithUsings = + string.Join(Environment.NewLine, defaultSystemNamespaces.Select(n => $"using {n};")) + + string.Join(Environment.NewLine, defaultCliFxNamespaces.Select(n => $"using {n};")) + + Environment.NewLine + + sourceCode; + + // Parse the source code + var ast = SyntaxFactory.ParseSyntaxTree( + SourceText.From(sourceCodeWithUsings), + CSharpParseOptions.Default + ); + + // Compile the code to IL + var compilation = CSharpCompilation.Create( + "CliFxTests_DynamicAssembly_" + Guid.NewGuid(), + new[] {ast}, + new[] + { + MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location), + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ICommand).Assembly.Location) + }, + // DLL to avoid having to define the Main() method + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var compilationErrors = compilation + .GetDiagnostics() + .Where(d => d.Severity >= DiagnosticSeverity.Error) + .ToArray(); + + if (compilationErrors.Any()) + { + throw new InvalidOperationException( + "Failed to compile code." + + Environment.NewLine + + string.Join(Environment.NewLine, compilationErrors.Select(e => e.ToString())) + ); + } + + return compilation; + } + + private IReadOnlyList GetProducedDiagnostics(string sourceCode) + { + var analyzers = ImmutableArray.Create(Subject); + var compilation = Compile(sourceCode); + + return compilation + .WithAnalyzers(analyzers) + .GetAnalyzerDiagnosticsAsync(analyzers, default) + .GetAwaiter() + .GetResult(); + } + + public void ProduceDiagnostics(string sourceCode) + { + var expectedDiagnostics = Subject.SupportedDiagnostics; + var producedDiagnostics = GetProducedDiagnostics(sourceCode); + + var expectedDiagnosticIds = expectedDiagnostics.Select(d => d.Id).Distinct().ToArray(); + var producedDiagnosticIds = producedDiagnostics.Select(d => d.Id).Distinct().ToArray(); + + var result = + expectedDiagnosticIds.Intersect(producedDiagnosticIds).Count() == + expectedDiagnosticIds.Length; + + Execute.Assertion.ForCondition(result).FailWith(() => + { + var buffer = new StringBuilder(); + + buffer.AppendLine("Expected and produced diagnostics do not match."); + buffer.AppendLine(); + + buffer.AppendLine("Expected diagnostics:"); + + foreach (var expectedDiagnostic in expectedDiagnostics) + { + buffer.Append(" - "); + buffer.Append(expectedDiagnostic.Id); + buffer.AppendLine(); + } + + buffer.AppendLine(); + + buffer.AppendLine("Produced diagnostics:"); + + foreach (var producedDiagnostic in producedDiagnostics) + { + buffer.Append(" - "); + buffer.Append(producedDiagnostic); + } + + return new FailReason(buffer.ToString()); + }); + } + + public void NotProduceDiagnostics(string sourceCode) + { + var producedDiagnostics = GetProducedDiagnostics(sourceCode); + + var result = !producedDiagnostics.Any(); + + Execute.Assertion.ForCondition(result).FailWith(() => + { + var buffer = new StringBuilder(); + + buffer.AppendLine("Expected no produced diagnostics."); + buffer.AppendLine(); + + buffer.AppendLine("Produced diagnostics:"); + + foreach (var producedDiagnostic in producedDiagnostics) + { + buffer.Append(" - "); + buffer.Append(producedDiagnostic); + } + + return new FailReason(buffer.ToString()); + }); + } + } + + internal static class AnalyzerAssertionsExtensions + { + public static AnalyzerAssertions Should(this DiagnosticAnalyzer analyzer) => new(analyzer); + } +} \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/xunit.runner.json b/CliFx.Analyzers.Tests/xunit.runner.json new file mode 100644 index 0000000..186540e --- /dev/null +++ b/CliFx.Analyzers.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplayOptions": "all", + "methodDisplay": "method" +} \ No newline at end of file diff --git a/CliFx.Analyzers/AnalyzerBase.cs b/CliFx.Analyzers/AnalyzerBase.cs new file mode 100644 index 0000000..3a2ee76 --- /dev/null +++ b/CliFx.Analyzers/AnalyzerBase.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + public abstract class AnalyzerBase : DiagnosticAnalyzer + { + public DiagnosticDescriptor SupportedDiagnostic { get; } + + public sealed override ImmutableArray SupportedDiagnostics { get; } + + protected AnalyzerBase( + string diagnosticTitle, + string diagnosticMessage, + DiagnosticSeverity diagnosticSeverity = DiagnosticSeverity.Error) + { + SupportedDiagnostic = new DiagnosticDescriptor( + "CliFx_" + GetType().Name.TrimEnd("Analyzer"), + diagnosticTitle, + diagnosticMessage, + "CliFx", + diagnosticSeverity, + true + ); + + SupportedDiagnostics = ImmutableArray.Create(SupportedDiagnostic); + } + + protected Diagnostic CreateDiagnostic(Location location) => + Diagnostic.Create(SupportedDiagnostic, location); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/CliFx.Analyzers.csproj b/CliFx.Analyzers/CliFx.Analyzers.csproj index 307f83c..1767ada 100644 --- a/CliFx.Analyzers/CliFx.Analyzers.csproj +++ b/CliFx.Analyzers/CliFx.Analyzers.csproj @@ -3,10 +3,11 @@ netstandard2.0 annotations + $(NoWarn);RS1025;RS1026 - + \ No newline at end of file diff --git a/CliFx.Analyzers/CommandMustBeAnnotatedAnalyzer.cs b/CliFx.Analyzers/CommandMustBeAnnotatedAnalyzer.cs new file mode 100644 index 0000000..ef9e9f9 --- /dev/null +++ b/CliFx.Analyzers/CommandMustBeAnnotatedAnalyzer.cs @@ -0,0 +1,59 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class CommandMustBeAnnotatedAnalyzer : AnalyzerBase + { + public CommandMustBeAnnotatedAnalyzer() + : base( + $"Commands must be annotated with `{SymbolNames.CliFxCommandAttribute}`", + $"This type must be annotated with `{SymbolNames.CliFxCommandAttribute}` in order to be a valid command.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not ClassDeclarationSyntax classDeclaration) + return; + + var type = context.SemanticModel.GetDeclaredSymbol(classDeclaration); + if (type is null) + return; + + // Ignore abstract classes, because they may be used to define + // base implementations for commands, in which case the command + // attribute doesn't make sense. + if (type.IsAbstract) + return; + + var implementsCommandInterface = type + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandInterface)); + + var hasCommandAttribute = type + .GetAttributes() + .Select(a => a.AttributeClass) + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandAttribute)); + + // If the interface is implemented, but the attribute is missing, + // then it's very likely a user error. + if (implementsCommandInterface && !hasCommandAttribute) + { + context.ReportDiagnostic(CreateDiagnostic(classDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/CommandMustImplementInterfaceAnalyzer.cs b/CliFx.Analyzers/CommandMustImplementInterfaceAnalyzer.cs new file mode 100644 index 0000000..ab98a5d --- /dev/null +++ b/CliFx.Analyzers/CommandMustImplementInterfaceAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class CommandMustImplementInterfaceAnalyzer : AnalyzerBase + { + public CommandMustImplementInterfaceAnalyzer() + : base( + $"Commands must implement `{SymbolNames.CliFxCommandInterface}` interface", + $"This type must implement `{SymbolNames.CliFxCommandInterface}` interface in order to be a valid command.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not ClassDeclarationSyntax classDeclaration) + return; + + var type = context.SemanticModel.GetDeclaredSymbol(classDeclaration); + if (type is null) + return; + + var hasCommandAttribute = type + .GetAttributes() + .Select(a => a.AttributeClass) + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandAttribute)); + + var implementsCommandInterface = type + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandInterface)); + + // If the attribute is present, but the interface is not implemented, + // it's very likely a user error. + if (hasCommandAttribute && !implementsCommandInterface) + { + context.ReportDiagnostic(CreateDiagnostic(classDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/CommandSchemaAnalyzer.cs b/CliFx.Analyzers/CommandSchemaAnalyzer.cs deleted file mode 100644 index 36aec8c..0000000 --- a/CliFx.Analyzers/CommandSchemaAnalyzer.cs +++ /dev/null @@ -1,423 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace CliFx.Analyzers -{ - // TODO: split into multiple analyzers - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class CommandSchemaAnalyzer : DiagnosticAnalyzer - { - public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( - DiagnosticDescriptors.CliFx0001, - DiagnosticDescriptors.CliFx0002, - DiagnosticDescriptors.CliFx0021, - DiagnosticDescriptors.CliFx0022, - DiagnosticDescriptors.CliFx0023, - DiagnosticDescriptors.CliFx0024, - DiagnosticDescriptors.CliFx0025, - DiagnosticDescriptors.CliFx0026, - DiagnosticDescriptors.CliFx0041, - DiagnosticDescriptors.CliFx0042, - DiagnosticDescriptors.CliFx0043, - DiagnosticDescriptors.CliFx0044, - DiagnosticDescriptors.CliFx0045, - DiagnosticDescriptors.CliFx0046, - DiagnosticDescriptors.CliFx0047, - DiagnosticDescriptors.CliFx0048, - DiagnosticDescriptors.CliFx0049 - ); - - private static bool IsScalarType(ITypeSymbol typeSymbol) => - KnownSymbols.IsSystemString(typeSymbol) || - !typeSymbol.AllInterfaces.Select(i => i.ConstructedFrom) - .Any(KnownSymbols.IsSystemCollectionsGenericIEnumerable); - - private static void CheckCommandParameterProperties( - SymbolAnalysisContext context, - IReadOnlyList properties) - { - var parameters = properties - .Select(p => - { - var attribute = p - .GetAttributes() - .First(a => KnownSymbols.IsCommandParameterAttribute(a.AttributeClass)); - - var order = attribute - .ConstructorArguments - .Select(a => a.Value) - .FirstOrDefault() as int?; - - var name = attribute - .NamedArguments - .Where(a => a.Key == "Name") - .Select(a => a.Value.Value) - .FirstOrDefault() as string; - - var converter = attribute - .NamedArguments - .Where(a => a.Key == "Converter") - .Select(a => a.Value.Value) - .Cast() - .FirstOrDefault(); - - var validators = attribute - .NamedArguments - .Where(a => a.Key == "Validators") - .SelectMany(a => a.Value.Values) - .Select(c => c.Value) - .Cast() - .ToArray(); - - return new - { - Property = p, - Order = order, - Name = name, - Converter = converter, - Validators = validators - }; - }) - .ToArray(); - - // Duplicate order - var duplicateOrderParameters = parameters - .Where(p => p.Order != null) - .GroupBy(p => p.Order) - .Where(g => g.Count() > 1) - .SelectMany(g => g.AsEnumerable()) - .ToArray(); - - foreach (var parameter in duplicateOrderParameters) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0021, parameter.Property.Locations.First() - )); - } - - // Duplicate name - var duplicateNameParameters = parameters - .Where(p => !string.IsNullOrWhiteSpace(p.Name)) - .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() > 1) - .SelectMany(g => g.AsEnumerable()) - .ToArray(); - - foreach (var parameter in duplicateNameParameters) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0022, parameter.Property.Locations.First() - )); - } - - // Multiple non-scalar - var nonScalarParameters = parameters - .Where(p => !IsScalarType(p.Property.Type)) - .ToArray(); - - if (nonScalarParameters.Length > 1) - { - foreach (var parameter in nonScalarParameters) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0023, parameter.Property.Locations.First() - )); - } - } - - // Non-last non-scalar - var nonLastNonScalarParameter = parameters - .OrderByDescending(a => a.Order) - .Skip(1) - .LastOrDefault(p => !IsScalarType(p.Property.Type)); - - if (nonLastNonScalarParameter != null) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0024, nonLastNonScalarParameter.Property.Locations.First() - )); - } - - // Invalid converter - var invalidConverterParameters = parameters - .Where(p => - p.Converter != null && - !p.Converter.AllInterfaces.Any(KnownSymbols.IsArgumentValueConverterInterface)) - .ToArray(); - - foreach (var parameter in invalidConverterParameters) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0025, parameter.Property.Locations.First() - )); - } - - // Invalid validators - var invalidValidatorsParameters = parameters - .Where(p => !p.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface))) - .ToArray(); - - foreach (var parameter in invalidValidatorsParameters) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0026, parameter.Property.Locations.First() - )); - } - } - - private static void CheckCommandOptionProperties( - SymbolAnalysisContext context, - IReadOnlyList properties) - { - var options = properties - .Select(p => - { - var attribute = p - .GetAttributes() - .First(a => KnownSymbols.IsCommandOptionAttribute(a.AttributeClass)); - - var name = attribute - .ConstructorArguments - .Where(a => KnownSymbols.IsSystemString(a.Type)) - .Select(a => a.Value) - .FirstOrDefault() as string; - - var shortName = attribute - .ConstructorArguments - .Where(a => KnownSymbols.IsSystemChar(a.Type)) - .Select(a => a.Value) - .FirstOrDefault() as char?; - - var envVarName = attribute - .NamedArguments - .Where(a => a.Key == "EnvironmentVariableName") - .Select(a => a.Value.Value) - .FirstOrDefault() as string; - - var converter = attribute - .NamedArguments - .Where(a => a.Key == "Converter") - .Select(a => a.Value.Value) - .Cast() - .FirstOrDefault(); - - var validators = attribute - .NamedArguments - .Where(a => a.Key == "Validators") - .SelectMany(a => a.Value.Values) - .Select(c => c.Value) - .Cast() - .ToArray(); - - return new - { - Property = p, - Name = name, - ShortName = shortName, - EnvironmentVariableName = envVarName, - Converter = converter, - Validators = validators - }; - }) - .ToArray(); - - // No name - var noNameOptions = options - .Where(o => string.IsNullOrWhiteSpace(o.Name) && o.ShortName == null) - .ToArray(); - - foreach (var option in noNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0041, option.Property.Locations.First() - )); - } - - // Too short name - var invalidNameLengthOptions = options - .Where(o => !string.IsNullOrWhiteSpace(o.Name) && o.Name.Length <= 1) - .ToArray(); - - foreach (var option in invalidNameLengthOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0042, option.Property.Locations.First() - )); - } - - // Duplicate name - var duplicateNameOptions = options - .Where(p => !string.IsNullOrWhiteSpace(p.Name)) - .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() > 1) - .SelectMany(g => g.AsEnumerable()) - .ToArray(); - - foreach (var option in duplicateNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0043, option.Property.Locations.First() - )); - } - - // Duplicate name - var duplicateShortNameOptions = options - .Where(p => p.ShortName != null) - .GroupBy(p => p.ShortName) - .Where(g => g.Count() > 1) - .SelectMany(g => g.AsEnumerable()) - .ToArray(); - - foreach (var option in duplicateShortNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0044, option.Property.Locations.First() - )); - } - - // Duplicate environment variable name - var duplicateEnvironmentVariableNameOptions = options - .Where(p => !string.IsNullOrWhiteSpace(p.EnvironmentVariableName)) - .GroupBy(p => p.EnvironmentVariableName, StringComparer.Ordinal) - .Where(g => g.Count() > 1) - .SelectMany(g => g.AsEnumerable()) - .ToArray(); - - foreach (var option in duplicateEnvironmentVariableNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0045, option.Property.Locations.First() - )); - } - - // Invalid converter - var invalidConverterOptions = options - .Where(o => - o.Converter != null && - !o.Converter.AllInterfaces.Any(KnownSymbols.IsArgumentValueConverterInterface)) - .ToArray(); - - foreach (var option in invalidConverterOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0046, option.Property.Locations.First() - )); - } - - // Invalid validators - var invalidValidatorsOptions = options - .Where(o => !o.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface))) - .ToArray(); - - foreach (var option in invalidValidatorsOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0047, option.Property.Locations.First() - )); - } - - // Non-letter first character in name - var nonLetterFirstCharacterInNameOptions = options - .Where(o => !string.IsNullOrWhiteSpace(o.Name) && !char.IsLetter(o.Name[0])) - .ToArray(); - - foreach (var option in nonLetterFirstCharacterInNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0048, option.Property.Locations.First() - )); - } - - // Non-letter short name - var nonLetterShortNameOptions = options - .Where(o => o.ShortName != null && !char.IsLetter(o.ShortName.Value)) - .ToArray(); - - foreach (var option in nonLetterShortNameOptions) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0049, option.Property.Locations.First() - )); - } - } - - private static void CheckCommandType(SymbolAnalysisContext context) - { - // Named type: MyCommand - if (!(context.Symbol is INamedTypeSymbol namedTypeSymbol) || - namedTypeSymbol.TypeKind != TypeKind.Class) - return; - - // Implements ICommand? - var implementsCommandInterface = namedTypeSymbol - .AllInterfaces - .Any(KnownSymbols.IsCommandInterface); - - // Has CommandAttribute? - var hasCommandAttribute = namedTypeSymbol - .GetAttributes() - .Select(a => a.AttributeClass) - .Any(KnownSymbols.IsCommandAttribute); - - var isValidCommandType = - // implements interface - implementsCommandInterface && ( - // and either abstract class or has attribute - namedTypeSymbol.IsAbstract || hasCommandAttribute - ); - - if (!isValidCommandType) - { - // See if this was meant to be a command type (either interface or attribute present) - var isAlmostValidCommandType = implementsCommandInterface ^ hasCommandAttribute; - - if (isAlmostValidCommandType && !implementsCommandInterface) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0001, - namedTypeSymbol.Locations.First())); - - if (isAlmostValidCommandType && !hasCommandAttribute) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0002, - namedTypeSymbol.Locations.First())); - - return; - } - - var properties = namedTypeSymbol - .GetMembers() - .Where(m => m.Kind == SymbolKind.Property) - .OfType().ToArray(); - - // Check parameters - var parameterProperties = properties - .Where(p => p - .GetAttributes() - .Select(a => a.AttributeClass) - .Any(KnownSymbols.IsCommandParameterAttribute)) - .ToArray(); - - CheckCommandParameterProperties(context, parameterProperties); - - // Check options - var optionsProperties = properties - .Where(p => p - .GetAttributes() - .Select(a => a.AttributeClass) - .Any(KnownSymbols.IsCommandOptionAttribute)) - .ToArray(); - - CheckCommandOptionProperties(context, optionsProperties); - } - - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - - context.RegisterSymbolAction(CheckCommandType, SymbolKind.NamedType); - } - } -} \ No newline at end of file diff --git a/CliFx.Analyzers/ConsoleUsageAnalyzer.cs b/CliFx.Analyzers/ConsoleUsageAnalyzer.cs deleted file mode 100644 index 31e3e3c..0000000 --- a/CliFx.Analyzers/ConsoleUsageAnalyzer.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Collections.Immutable; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace CliFx.Analyzers -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ConsoleUsageAnalyzer : DiagnosticAnalyzer - { - public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( - DiagnosticDescriptors.CliFx0100 - ); - - private static bool IsSystemConsoleInvocation( - SyntaxNodeAnalysisContext context, - InvocationExpressionSyntax invocationSyntax) - { - if (invocationSyntax.Expression is MemberAccessExpressionSyntax memberAccessSyntax && - context.SemanticModel.GetSymbolInfo(memberAccessSyntax).Symbol is IMethodSymbol methodSymbol) - { - // Direct call to System.Console (e.g. System.Console.WriteLine()) - if (KnownSymbols.IsSystemConsole(methodSymbol.ContainingType)) - { - return true; - } - - // Indirect call to System.Console (e.g. System.Console.Error.WriteLine()) - if (memberAccessSyntax.Expression is MemberAccessExpressionSyntax parentMemberAccessSyntax && - context.SemanticModel.GetSymbolInfo(parentMemberAccessSyntax).Symbol is IPropertySymbol propertySymbol) - { - return KnownSymbols.IsSystemConsole(propertySymbol.ContainingType); - } - } - - return false; - } - - private static void CheckSystemConsoleUsage(SyntaxNodeAnalysisContext context) - { - if (context.Node is InvocationExpressionSyntax invocationSyntax && - IsSystemConsoleInvocation(context, invocationSyntax)) - { - // Check if IConsole is available in scope as alternative to System.Console - var isConsoleInterfaceAvailable = invocationSyntax - .Ancestors() - .OfType() - .SelectMany(m => m.ParameterList.Parameters) - .Select(p => p.Type) - .Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol) - .Where(s => s != null) - .Any(KnownSymbols.IsConsoleInterface!); - - if (isConsoleInterfaceAvailable) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.CliFx0100, - invocationSyntax.GetLocation() - )); - } - } - } - - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - - context.RegisterSyntaxNodeAction(CheckSystemConsoleUsage, SyntaxKind.InvocationExpression); - } - } -} \ No newline at end of file diff --git a/CliFx.Analyzers/DiagnosticDescriptors.cs b/CliFx.Analyzers/DiagnosticDescriptors.cs deleted file mode 100644 index 1d4cb43..0000000 --- a/CliFx.Analyzers/DiagnosticDescriptors.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace CliFx.Analyzers -{ - public static class DiagnosticDescriptors - { - public static readonly DiagnosticDescriptor CliFx0001 = - new(nameof(CliFx0001), - "Type must implement the 'CliFx.ICommand' interface in order to be a valid command", - "Type must implement the 'CliFx.ICommand' interface in order to be a valid command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0002 = - new(nameof(CliFx0002), - "Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command", - "Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0021 = - new(nameof(CliFx0021), - "Parameter order must be unique within its command", - "Parameter order must be unique within its command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0022 = - new(nameof(CliFx0022), - "Parameter order must have unique name within its command", - "Parameter order must have unique name within its command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0023 = - new(nameof(CliFx0023), - "Only one non-scalar parameter per command is allowed", - "Only one non-scalar parameter per command is allowed", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0024 = - new(nameof(CliFx0024), - "Non-scalar parameter must be last in order", - "Non-scalar parameter must be last in order", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0025 = - new(nameof(CliFx0025), - "Parameter converter must implement 'CliFx.IArgumentValueConverter'", - "Parameter converter must implement 'CliFx.IArgumentValueConverter'", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0026 = - new(nameof(CliFx0026), - "Parameter validator must implement 'CliFx.ArgumentValueValidator'", - "Parameter validator must implement 'CliFx.ArgumentValueValidator'", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0041 = - new(nameof(CliFx0041), - "Option must have a name or short name specified", - "Option must have a name or short name specified", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0042 = - new(nameof(CliFx0042), - "Option name must be at least 2 characters long", - "Option name must be at least 2 characters long", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0043 = - new(nameof(CliFx0043), - "Option name must be unique within its command", - "Option name must be unique within its command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0044 = - new(nameof(CliFx0044), - "Option short name must be unique within its command", - "Option short name must be unique within its command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0045 = - new(nameof(CliFx0045), - "Option environment variable name must be unique within its command", - "Option environment variable name must be unique within its command", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0046 = - new(nameof(CliFx0046), - "Option converter must implement 'CliFx.IArgumentValueConverter'", - "Option converter must implement 'CliFx.IArgumentValueConverter'", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0047 = - new(nameof(CliFx0047), - "Option validator must implement 'CliFx.ArgumentValueValidator'", - "Option validator must implement 'CliFx.ArgumentValueValidator'", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0048 = - new(nameof(CliFx0048), - "Option name must begin with a letter character.", - "Option name must begin with a letter character.", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0049 = - new(nameof(CliFx0049), - "Option short name must be a letter character.", - "Option short name must be a letter character.", - "Usage", DiagnosticSeverity.Error, true - ); - - public static readonly DiagnosticDescriptor CliFx0100 = - new(nameof(CliFx0100), - "Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation", - "Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation", - "Usage", DiagnosticSeverity.Warning, true - ); - } -} \ No newline at end of file diff --git a/CliFx.Analyzers/KnownSymbols.cs b/CliFx.Analyzers/KnownSymbols.cs deleted file mode 100644 index 329a0a4..0000000 --- a/CliFx.Analyzers/KnownSymbols.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CliFx.Analyzers.Internal; -using Microsoft.CodeAnalysis; - -namespace CliFx.Analyzers -{ - internal static class KnownSymbols - { - public static bool IsSystemString(ISymbol symbol) => - symbol.DisplayNameMatches("string") || - symbol.DisplayNameMatches("System.String"); - - public static bool IsSystemChar(ISymbol symbol) => - symbol.DisplayNameMatches("char") || - symbol.DisplayNameMatches("System.Char"); - - public static bool IsSystemCollectionsGenericIEnumerable(ISymbol symbol) => - symbol.DisplayNameMatches("System.Collections.Generic.IEnumerable"); - - public static bool IsSystemConsole(ISymbol symbol) => - symbol.DisplayNameMatches("System.Console"); - - public static bool IsConsoleInterface(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.IConsole"); - - public static bool IsCommandInterface(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.ICommand"); - - public static bool IsArgumentValueConverterInterface(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.IArgumentValueConverter"); - - public static bool IsArgumentValueValidatorInterface(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.IArgumentValueValidator"); - - public static bool IsCommandAttribute(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.Attributes.CommandAttribute"); - - public static bool IsCommandParameterAttribute(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.Attributes.CommandParameterAttribute"); - - public static bool IsCommandOptionAttribute(ISymbol symbol) => - symbol.DisplayNameMatches("CliFx.Attributes.CommandOptionAttribute"); - } -} \ No newline at end of file diff --git a/CliFx.Analyzers/ObjectModel/CommandOptionSymbol.cs b/CliFx.Analyzers/ObjectModel/CommandOptionSymbol.cs new file mode 100644 index 0000000..3321feb --- /dev/null +++ b/CliFx.Analyzers/ObjectModel/CommandOptionSymbol.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using System.Linq; +using CliFx.Analyzers.Utils.Extensions; + +namespace CliFx.Analyzers.ObjectModel +{ + internal partial class CommandOptionSymbol + { + public string? Name { get; } + + public char? ShortName { get; } + + public ITypeSymbol? ConverterType { get; } + + public IReadOnlyList ValidatorTypes { get; } + + public CommandOptionSymbol( + string? name, + char? shortName, + ITypeSymbol? converterType, + IReadOnlyList validatorTypes) + { + Name = name; + ShortName = shortName; + ConverterType = converterType; + ValidatorTypes = validatorTypes; + } + } + + internal partial class CommandOptionSymbol + { + private static AttributeData? TryGetOptionAttribute(IPropertySymbol property) => + property + .GetAttributes() + .FirstOrDefault(a => a.AttributeClass.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute)); + + private static CommandOptionSymbol FromAttribute(AttributeData attribute) + { + var name = attribute + .ConstructorArguments + .Where(a => a.Type.DisplayNameMatches("string") || a.Type.DisplayNameMatches("System.String")) + .Select(a => a.Value) + .FirstOrDefault() as string; + + var shortName = attribute + .ConstructorArguments + .Where(a => a.Type.DisplayNameMatches("char") || a.Type.DisplayNameMatches("System.Char")) + .Select(a => a.Value) + .FirstOrDefault() as char?; + + var converter = attribute + .NamedArguments + .Where(a => a.Key == "Converter") + .Select(a => a.Value.Value) + .Cast() + .FirstOrDefault(); + + var validators = attribute + .NamedArguments + .Where(a => a.Key == "Validators") + .SelectMany(a => a.Value.Values) + .Select(c => c.Value) + .Cast() + .ToArray(); + + return new CommandOptionSymbol(name, shortName, converter, validators); + } + + public static CommandOptionSymbol? TryResolve(IPropertySymbol property) + { + var attribute = TryGetOptionAttribute(property); + + if (attribute is null) + return null; + + return FromAttribute(attribute); + } + + public static bool IsOptionProperty(IPropertySymbol property) => + TryGetOptionAttribute(property) is not null; + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ObjectModel/CommandParameterSymbol.cs b/CliFx.Analyzers/ObjectModel/CommandParameterSymbol.cs new file mode 100644 index 0000000..deaf9f7 --- /dev/null +++ b/CliFx.Analyzers/ObjectModel/CommandParameterSymbol.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; + +namespace CliFx.Analyzers.ObjectModel +{ + internal partial class CommandParameterSymbol + { + public int Order { get; } + + public string? Name { get; } + + public ITypeSymbol? ConverterType { get; } + + public IReadOnlyList ValidatorTypes { get; } + + public CommandParameterSymbol( + int order, + string? name, + ITypeSymbol? converterType, + IReadOnlyList validatorTypes) + { + Order = order; + Name = name; + ConverterType = converterType; + ValidatorTypes = validatorTypes; + } + } + + internal partial class CommandParameterSymbol + { + private static AttributeData? TryGetParameterAttribute(IPropertySymbol property) => + property + .GetAttributes() + .FirstOrDefault(a => a.AttributeClass.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute)); + + private static CommandParameterSymbol FromAttribute(AttributeData attribute) + { + var order = (int) attribute + .ConstructorArguments + .Select(a => a.Value) + .First()!; + + var name = attribute + .NamedArguments + .Where(a => a.Key == "Name") + .Select(a => a.Value.Value) + .FirstOrDefault() as string; + + var converter = attribute + .NamedArguments + .Where(a => a.Key == "Converter") + .Select(a => a.Value.Value) + .Cast() + .FirstOrDefault(); + + var validators = attribute + .NamedArguments + .Where(a => a.Key == "Validators") + .SelectMany(a => a.Value.Values) + .Select(c => c.Value) + .Cast() + .ToArray(); + + return new CommandParameterSymbol(order, name, converter, validators); + } + + public static CommandParameterSymbol? TryResolve(IPropertySymbol property) + { + var attribute = TryGetParameterAttribute(property); + + if (attribute is null) + return null; + + return FromAttribute(attribute); + } + + public static bool IsParameterProperty(IPropertySymbol property) => + TryGetParameterAttribute(property) is not null; + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ObjectModel/SymbolNames.cs b/CliFx.Analyzers/ObjectModel/SymbolNames.cs new file mode 100644 index 0000000..aaa35a8 --- /dev/null +++ b/CliFx.Analyzers/ObjectModel/SymbolNames.cs @@ -0,0 +1,15 @@ +namespace CliFx.Analyzers.ObjectModel +{ + internal static class SymbolNames + { + public const string CliFxCommandInterface = "CliFx.ICommand"; + public const string CliFxCommandAttribute = "CliFx.Attributes.CommandAttribute"; + public const string CliFxCommandParameterAttribute = "CliFx.Attributes.CommandParameterAttribute"; + public const string CliFxCommandOptionAttribute = "CliFx.Attributes.CommandOptionAttribute"; + public const string CliFxConsoleInterface = "CliFx.Infrastructure.IConsole"; + public const string CliFxBindingConverterInterface = "CliFx.Extensibility.IBindingConverter"; + public const string CliFxBindingConverterClass = "CliFx.Extensibility.BindingConverter"; + public const string CliFxBindingValidatorInterface = "CliFx.Extensibility.IBindingValidator"; + public const string CliFxBindingValidatorClass = "CliFx.Extensibility.BindingValidator"; + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustBeInsideCommandAnalyzer.cs b/CliFx.Analyzers/OptionMustBeInsideCommandAnalyzer.cs new file mode 100644 index 0000000..a46caf4 --- /dev/null +++ b/CliFx.Analyzers/OptionMustBeInsideCommandAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustBeInsideCommandAnalyzer : AnalyzerBase + { + public OptionMustBeInsideCommandAnalyzer() + : base( + "Options must be defined inside commands", + $"This option must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + if (property.ContainingType.IsAbstract) + return; + + if (!CommandOptionSymbol.IsOptionProperty(property)) + return; + + var isInsideCommand = property + .ContainingType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandInterface)); + + if (!isInsideCommand) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveNameOrShortNameAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveNameOrShortNameAnalyzer.cs new file mode 100644 index 0000000..24ce9b6 --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveNameOrShortNameAnalyzer.cs @@ -0,0 +1,44 @@ +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveNameOrShortNameAnalyzer : AnalyzerBase + { + public OptionMustHaveNameOrShortNameAnalyzer() + : base( + "Options must have either a name or short name specified", + "This option must have either a name or short name specified.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (string.IsNullOrWhiteSpace(option.Name) && option.ShortName is null) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveUniqueNameAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveUniqueNameAnalyzer.cs new file mode 100644 index 0000000..948e59d --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveUniqueNameAnalyzer.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveUniqueNameAnalyzer : AnalyzerBase + { + public OptionMustHaveUniqueNameAnalyzer() + : base( + "Options must have unique names", + "This option's name must be unique within the command (comparison IS NOT case sensitive).") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (string.IsNullOrWhiteSpace(option.Name)) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + var otherOption = CommandOptionSymbol.TryResolve(otherProperty); + if (otherOption is null) + continue; + + if (string.IsNullOrWhiteSpace(otherOption.Name)) + continue; + + if (string.Equals(option.Name, otherOption.Name, StringComparison.OrdinalIgnoreCase)) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveUniqueShortNameAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveUniqueShortNameAnalyzer.cs new file mode 100644 index 0000000..2dab96a --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveUniqueShortNameAnalyzer.cs @@ -0,0 +1,65 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveUniqueShortNameAnalyzer : AnalyzerBase + { + public OptionMustHaveUniqueShortNameAnalyzer() + : base( + "Options must have unique short names", + "This option's short name must be unique within the command (comparison IS case sensitive).") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (option.ShortName is null) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + var otherOption = CommandOptionSymbol.TryResolve(otherProperty); + if (otherOption is null) + continue; + + if (otherOption.ShortName is null) + continue; + + if (option.ShortName == otherOption.ShortName) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveValidConverterAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveValidConverterAnalyzer.cs new file mode 100644 index 0000000..9a4f104 --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveValidConverterAnalyzer.cs @@ -0,0 +1,55 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveValidConverterAnalyzer : AnalyzerBase + { + public OptionMustHaveValidConverterAnalyzer() + : base( + $"Option converters must derive from `{SymbolNames.CliFxBindingConverterClass}`", + $"Converter specified for this option must derive from `{SymbolNames.CliFxBindingConverterClass}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (option.ConverterType is null) + return; + + // We check against an internal interface because checking against a generic class is a pain + var converterImplementsInterface = option + .ConverterType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxBindingConverterInterface)); + + if (!converterImplementsInterface) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveValidNameAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveValidNameAnalyzer.cs new file mode 100644 index 0000000..c056838 --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveValidNameAnalyzer.cs @@ -0,0 +1,47 @@ +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveValidNameAnalyzer : AnalyzerBase + { + public OptionMustHaveValidNameAnalyzer() + : base( + "Options must have valid names", + "This option's name must be at least 2 characters long and must start with a letter.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (string.IsNullOrWhiteSpace(option.Name)) + return; + + if (option.Name.Length < 2 || !char.IsLetter(option.Name[0])) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveValidShortNameAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveValidShortNameAnalyzer.cs new file mode 100644 index 0000000..dfeabee --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveValidShortNameAnalyzer.cs @@ -0,0 +1,47 @@ +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveValidShortNameAnalyzer : AnalyzerBase + { + public OptionMustHaveValidShortNameAnalyzer() + : base( + "Option short names must be letter characters", + "This option's short name must be a single letter character.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + if (option.ShortName is null) + return; + + if (!char.IsLetter(option.ShortName.Value)) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/OptionMustHaveValidValidatorsAnalyzer.cs b/CliFx.Analyzers/OptionMustHaveValidValidatorsAnalyzer.cs new file mode 100644 index 0000000..390bb74 --- /dev/null +++ b/CliFx.Analyzers/OptionMustHaveValidValidatorsAnalyzer.cs @@ -0,0 +1,57 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OptionMustHaveValidValidatorsAnalyzer : AnalyzerBase + { + public OptionMustHaveValidValidatorsAnalyzer() + : base( + $"Option validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`", + $"All validators specified for this option must derive from `{SymbolNames.CliFxBindingValidatorClass}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var option = CommandOptionSymbol.TryResolve(property); + if (option is null) + return; + + foreach (var validatorType in option.ValidatorTypes) + { + // We check against an internal interface because checking against a generic class is a pain + var validatorImplementsInterface = validatorType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxBindingValidatorInterface)); + + if (!validatorImplementsInterface) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + + // No need to report multiple identical diagnostics on the same node + break; + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustBeInsideCommandAnalyzer.cs b/CliFx.Analyzers/ParameterMustBeInsideCommandAnalyzer.cs new file mode 100644 index 0000000..9116674 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustBeInsideCommandAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustBeInsideCommandAnalyzer : AnalyzerBase + { + public ParameterMustBeInsideCommandAnalyzer() + : base( + "Parameters must be defined inside commands", + $"This parameter must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + if (property.ContainingType.IsAbstract) + return; + + if (!CommandParameterSymbol.IsParameterProperty(property)) + return; + + var isInsideCommand = property + .ContainingType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxCommandInterface)); + + if (!isInsideCommand) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustBeLastIfNonScalarAnalyzer.cs b/CliFx.Analyzers/ParameterMustBeLastIfNonScalarAnalyzer.cs new file mode 100644 index 0000000..10e3f17 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustBeLastIfNonScalarAnalyzer.cs @@ -0,0 +1,70 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustBeLastIfNonScalarAnalyzer : AnalyzerBase + { + public ParameterMustBeLastIfNonScalarAnalyzer() + : base( + "Parameters of non-scalar types must be last in order", + "This parameter has a non-scalar type so it must be last in order (its order must be highest within the command).") + { + } + + private static bool IsScalar(ITypeSymbol type) => + type.DisplayNameMatches("string") || + type.DisplayNameMatches("System.String") || + !type.AllInterfaces + .Select(i => i.ConstructedFrom) + .Any(s => s.DisplayNameMatches("System.Collections.Generic.IEnumerable")); + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + if (IsScalar(property.Type)) + return; + + var parameter = CommandParameterSymbol.TryResolve(property); + if (parameter is null) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); + if (otherParameter is null) + continue; + + if (otherParameter.Order > parameter.Order) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustBeSingleIfNonScalarAnalyzer.cs b/CliFx.Analyzers/ParameterMustBeSingleIfNonScalarAnalyzer.cs new file mode 100644 index 0000000..13bbe34 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustBeSingleIfNonScalarAnalyzer.cs @@ -0,0 +1,68 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustBeSingleIfNonScalarAnalyzer : AnalyzerBase + { + public ParameterMustBeSingleIfNonScalarAnalyzer() + : base( + "Parameters of non-scalar types are limited to one per command", + "This parameter has a non-scalar type so it must be the only such parameter in the command.") + { + } + + private static bool IsScalar(ITypeSymbol type) => + type.DisplayNameMatches("string") || + type.DisplayNameMatches("System.String") || + !type.AllInterfaces + .Select(i => i.ConstructedFrom) + .Any(s => s.DisplayNameMatches("System.Collections.Generic.IEnumerable")); + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + if (!CommandParameterSymbol.IsParameterProperty(property)) + return; + + if (IsScalar(property.Type)) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + if (!CommandParameterSymbol.IsParameterProperty(otherProperty)) + continue; + + if (!IsScalar(otherProperty.Type)) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustHaveUniqueNameAnalyzer.cs b/CliFx.Analyzers/ParameterMustHaveUniqueNameAnalyzer.cs new file mode 100644 index 0000000..6387561 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustHaveUniqueNameAnalyzer.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustHaveUniqueNameAnalyzer : AnalyzerBase + { + public ParameterMustHaveUniqueNameAnalyzer() + : base( + "Parameters must have unique names", + "This parameter's name must be unique within the command (comparison IS NOT case sensitive).") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var parameter = CommandParameterSymbol.TryResolve(property); + if (parameter is null) + return; + + if (string.IsNullOrWhiteSpace(parameter.Name)) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); + if (otherParameter is null) + continue; + + if (string.IsNullOrWhiteSpace(otherParameter.Name)) + continue; + + if (string.Equals(parameter.Name, otherParameter.Name, StringComparison.OrdinalIgnoreCase)) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustHaveUniqueOrderAnalyzer.cs b/CliFx.Analyzers/ParameterMustHaveUniqueOrderAnalyzer.cs new file mode 100644 index 0000000..1a78d79 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustHaveUniqueOrderAnalyzer.cs @@ -0,0 +1,59 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustHaveUniqueOrderAnalyzer : AnalyzerBase + { + public ParameterMustHaveUniqueOrderAnalyzer() + : base( + "Parameters must have unique order", + "This parameter's order must be unique within the command.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var parameter = CommandParameterSymbol.TryResolve(property); + if (parameter is null) + return; + + var otherProperties = property + .ContainingType + .GetMembers() + .OfType() + .Where(m => !m.Equals(property, SymbolEqualityComparer.Default)) + .ToArray(); + + foreach (var otherProperty in otherProperties) + { + var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); + if (otherParameter is null) + continue; + + if (parameter.Order == otherParameter.Order) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustHaveValidConverterAnalyzer.cs b/CliFx.Analyzers/ParameterMustHaveValidConverterAnalyzer.cs new file mode 100644 index 0000000..54a2607 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustHaveValidConverterAnalyzer.cs @@ -0,0 +1,55 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustHaveValidConverterAnalyzer : AnalyzerBase + { + public ParameterMustHaveValidConverterAnalyzer() + : base( + $"Parameter converters must derive from `{SymbolNames.CliFxBindingConverterClass}`", + $"Converter specified for this parameter must derive from `{SymbolNames.CliFxBindingConverterClass}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var parameter = CommandParameterSymbol.TryResolve(property); + if (parameter is null) + return; + + if (parameter.ConverterType is null) + return; + + // We check against an internal interface because checking against a generic class is a pain + var converterImplementsInterface = parameter + .ConverterType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxBindingConverterInterface)); + + if (!converterImplementsInterface) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/ParameterMustHaveValidValidatorsAnalyzer.cs b/CliFx.Analyzers/ParameterMustHaveValidValidatorsAnalyzer.cs new file mode 100644 index 0000000..fe6e481 --- /dev/null +++ b/CliFx.Analyzers/ParameterMustHaveValidValidatorsAnalyzer.cs @@ -0,0 +1,57 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ParameterMustHaveValidValidatorsAnalyzer : AnalyzerBase + { + public ParameterMustHaveValidValidatorsAnalyzer() + : base( + $"Parameter validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`", + $"All validators specified for this parameter must derive from `{SymbolNames.CliFxBindingValidatorClass}`.") + { + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) + return; + + var property = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + if (property is null) + return; + + var parameter = CommandParameterSymbol.TryResolve(property); + if (parameter is null) + return; + + foreach (var validatorType in parameter.ValidatorTypes) + { + // We check against an internal interface because checking against a generic class is a pain + var validatorImplementsInterface = validatorType + .AllInterfaces + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxBindingValidatorInterface)); + + if (!validatorImplementsInterface) + { + context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.GetLocation())); + + // No need to report multiple identical diagnostics on the same node + break; + } + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.PropertyDeclaration); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/SystemConsoleShouldBeAvoidedAnalyzer.cs b/CliFx.Analyzers/SystemConsoleShouldBeAvoidedAnalyzer.cs new file mode 100644 index 0000000..83ff5c1 --- /dev/null +++ b/CliFx.Analyzers/SystemConsoleShouldBeAvoidedAnalyzer.cs @@ -0,0 +1,77 @@ +using System.Linq; +using CliFx.Analyzers.ObjectModel; +using CliFx.Analyzers.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class SystemConsoleShouldBeAvoidedAnalyzer : AnalyzerBase + { + public SystemConsoleShouldBeAvoidedAnalyzer() + : base( + $"Avoid calling `System.Console` where `{SymbolNames.CliFxConsoleInterface}` is available", + $"Use the provided `{SymbolNames.CliFxConsoleInterface}` abstraction instead of `System.Console` to ensure that the command can be tested in isolation.", + DiagnosticSeverity.Warning) + { + } + + private MemberAccessExpressionSyntax? TryGetSystemConsoleMemberAccess( + SyntaxNodeAnalysisContext context, + SyntaxNode node) + { + var currentNode = node; + + while (currentNode is MemberAccessExpressionSyntax memberAccess) + { + var symbol = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol; + + if (symbol is not null && symbol.ContainingType.DisplayNameMatches("System.Console")) + { + return memberAccess; + } + + // Get inner expression, which may be another member access expression. + // Example: System.Console.Error + // ~~~~~~~~~~~~~~ <- inner member access expression + // -------------------- <- outer member access expression + currentNode = memberAccess.Expression; + } + + return null; + } + + private void Analyze(SyntaxNodeAnalysisContext context) + { + // Try to get a member access on System.Console in the current expression, + // or in any of its inner expressions. + var systemConsoleMemberAccess = TryGetSystemConsoleMemberAccess(context, context.Node); + if (systemConsoleMemberAccess is null) + return; + + // Check if IConsole is available in scope as an alternative to System.Console + var isConsoleInterfaceAvailable = context.Node + .Ancestors() + .OfType() + .SelectMany(m => m.ParameterList.Parameters) + .Select(p => p.Type) + .Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol) + .Where(s => s is not null) + .Any(s => s.DisplayNameMatches(SymbolNames.CliFxConsoleInterface)); + + if (isConsoleInterfaceAvailable) + { + context.ReportDiagnostic(CreateDiagnostic(systemConsoleMemberAccess.GetLocation())); + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.SimpleMemberAccessExpression); + } + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/Internal/RoslynExtensions.cs b/CliFx.Analyzers/Utils/Extensions/RoslynExtensions.cs similarity index 86% rename from CliFx.Analyzers/Internal/RoslynExtensions.cs rename to CliFx.Analyzers/Utils/Extensions/RoslynExtensions.cs index cc4e46c..adfb210 100644 --- a/CliFx.Analyzers/Internal/RoslynExtensions.cs +++ b/CliFx.Analyzers/Utils/Extensions/RoslynExtensions.cs @@ -1,7 +1,7 @@ using System; using Microsoft.CodeAnalysis; -namespace CliFx.Analyzers.Internal +namespace CliFx.Analyzers.Utils.Extensions { internal static class RoslynExtensions { diff --git a/CliFx.Analyzers/Utils/Extensions/StringExtensions.cs b/CliFx.Analyzers/Utils/Extensions/StringExtensions.cs new file mode 100644 index 0000000..a8d9c7c --- /dev/null +++ b/CliFx.Analyzers/Utils/Extensions/StringExtensions.cs @@ -0,0 +1,18 @@ +using System; + +namespace CliFx.Analyzers.Utils.Extensions +{ + internal static class StringExtensions + { + public static string TrimEnd( + this string str, + string sub, + StringComparison comparison = StringComparison.Ordinal) + { + while (str.EndsWith(sub, comparison)) + str = str.Substring(0, str.Length - sub.Length); + + return str; + } + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.CliFx.cs b/CliFx.Benchmarks/Benchmarks.CliFx.cs new file mode 100644 index 0000000..62ca025 --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.CliFx.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + [Command] + public class CliFxCommand : ICommand + { + [CommandOption("str", 's')] + public string? StrOption { get; set; } + + [CommandOption("int", 'i')] + public int IntOption { get; set; } + + [CommandOption("bool", 'b')] + public bool BoolOption { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + + [Benchmark(Description = "CliFx", Baseline = true)] + public async ValueTask ExecuteWithCliFx() => + await new CliApplicationBuilder() + .AddCommand() + .Build() + .RunAsync(Arguments, new Dictionary()); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.Clipr.cs b/CliFx.Benchmarks/Benchmarks.Clipr.cs new file mode 100644 index 0000000..8f8eb11 --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.Clipr.cs @@ -0,0 +1,27 @@ +using BenchmarkDotNet.Attributes; +using clipr; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class CliprCommand + { + [NamedArgument('s', "str")] + public string? StrOption { get; set; } + + [NamedArgument('i', "int")] + public int IntOption { get; set; } + + [NamedArgument('b', "bool", Constraint = NumArgsConstraint.Optional, Const = true)] + public bool BoolOption { get; set; } + + public void Execute() + { + } + } + + [Benchmark(Description = "Clipr")] + public void ExecuteWithClipr() => CliParser.Parse(Arguments).Execute(); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.Cocona.cs b/CliFx.Benchmarks/Benchmarks.Cocona.cs new file mode 100644 index 0000000..e9f994f --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.Cocona.cs @@ -0,0 +1,24 @@ +using BenchmarkDotNet.Attributes; +using Cocona; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class CoconaCommand + { + public void Execute( + [Option("str", new []{'s'})] + string? strOption, + [Option("int", new []{'i'})] + int intOption, + [Option("bool", new []{'b'})] + bool boolOption) + { + } + } + + [Benchmark(Description = "Cocona")] + public void ExecuteWithCocona() => CoconaApp.Run(Arguments); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.CommandLineParser.cs b/CliFx.Benchmarks/Benchmarks.CommandLineParser.cs new file mode 100644 index 0000000..a5a65cb --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.CommandLineParser.cs @@ -0,0 +1,30 @@ +using BenchmarkDotNet.Attributes; +using CommandLine; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class CommandLineParserCommand + { + [Option('s', "str")] + public string? StrOption { get; set; } + + [Option('i', "int")] + public int IntOption { get; set; } + + [Option('b', "bool")] + public bool BoolOption { get; set; } + + public void Execute() + { + } + } + + [Benchmark(Description = "CommandLineParser")] + public void ExecuteWithCommandLineParser() => + new Parser() + .ParseArguments(Arguments, typeof(CommandLineParserCommand)) + .WithParsed(c => c.Execute()); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.McMaster.cs b/CliFx.Benchmarks/Benchmarks.McMaster.cs new file mode 100644 index 0000000..ab0e827 --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.McMaster.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Attributes; +using McMaster.Extensions.CommandLineUtils; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class McMasterCommand + { + [Option("--str|-s")] + public string? StrOption { get; set; } + + [Option("--int|-i")] + public int IntOption { get; set; } + + [Option("--bool|-b")] + public bool BoolOption { get; set; } + + public int OnExecute() => 0; + } + + [Benchmark(Description = "McMaster.Extensions.CommandLineUtils")] + public int ExecuteWithMcMaster() => CommandLineApplication.Execute(Arguments); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.PowerArgs.cs b/CliFx.Benchmarks/Benchmarks.PowerArgs.cs new file mode 100644 index 0000000..9ce03c7 --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.PowerArgs.cs @@ -0,0 +1,27 @@ +using BenchmarkDotNet.Attributes; +using PowerArgs; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class PowerArgsCommand + { + [ArgShortcut("--str"), ArgShortcut("-s")] + public string? StrOption { get; set; } + + [ArgShortcut("--int"), ArgShortcut("-i")] + public int IntOption { get; set; } + + [ArgShortcut("--bool"), ArgShortcut("-b")] + public bool BoolOption { get; set; } + + public void Main() + { + } + } + + [Benchmark(Description = "PowerArgs")] + public void ExecuteWithPowerArgs() => Args.InvokeMain(Arguments); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.SystemCommandLine.cs b/CliFx.Benchmarks/Benchmarks.SystemCommandLine.cs new file mode 100644 index 0000000..7475fce --- /dev/null +++ b/CliFx.Benchmarks/Benchmarks.SystemCommandLine.cs @@ -0,0 +1,44 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace CliFx.Benchmarks +{ + public partial class Benchmarks + { + public class SystemCommandLineCommand + { + public static int ExecuteHandler(string s, int i, bool b) => 0; + + public Task ExecuteAsync(string[] args) + { + var command = new RootCommand + { + new Option(new[] {"--str", "-s"}) + { + Argument = new Argument() + }, + new Option(new[] {"--int", "-i"}) + { + Argument = new Argument() + }, + new Option(new[] {"--bool", "-b"}) + { + Argument = new Argument() + } + }; + + command.Handler = CommandHandler.Create( + typeof(SystemCommandLineCommand).GetMethod(nameof(ExecuteHandler))! + ); + + return command.InvokeAsync(args); + } + } + + [Benchmark(Description = "System.CommandLine")] + public async Task ExecuteWithSystemCommandLine() => + await new SystemCommandLineCommand().ExecuteAsync(Arguments); + } +} \ No newline at end of file diff --git a/CliFx.Benchmarks/Benchmarks.cs b/CliFx.Benchmarks/Benchmarks.cs index 37a49cd..c115649 100644 --- a/CliFx.Benchmarks/Benchmarks.cs +++ b/CliFx.Benchmarks/Benchmarks.cs @@ -1,52 +1,20 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Order; using BenchmarkDotNet.Running; -using CliFx.Benchmarks.Commands; -using CommandLine; namespace CliFx.Benchmarks { - [SimpleJob] [RankColumn] [Orderer(SummaryOrderPolicy.FastestToSlowest)] - public class Benchmarks + public partial class Benchmarks { private static readonly string[] Arguments = {"--str", "hello world", "-i", "13", "-b"}; - [Benchmark(Description = "CliFx", Baseline = true)] - public async ValueTask ExecuteWithCliFx() => - await new CliApplicationBuilder().AddCommand().Build().RunAsync(Arguments, new Dictionary()); - - [Benchmark(Description = "System.CommandLine")] - public async Task ExecuteWithSystemCommandLine() => - await new SystemCommandLineCommand().ExecuteAsync(Arguments); - - [Benchmark(Description = "McMaster.Extensions.CommandLineUtils")] - public int ExecuteWithMcMaster() => - McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute(Arguments); - - [Benchmark(Description = "CommandLineParser")] - public void ExecuteWithCommandLineParser() => - new Parser() - .ParseArguments(Arguments, typeof(CommandLineParserCommand)) - .WithParsed(c => c.Execute()); - - [Benchmark(Description = "PowerArgs")] - public void ExecuteWithPowerArgs() => - PowerArgs.Args.InvokeMain(Arguments); - - [Benchmark(Description = "Clipr")] - public void ExecuteWithClipr() => - clipr.CliParser.Parse(Arguments).Execute(); - - [Benchmark(Description = "Cocona")] - public void ExecuteWithCocona() => - Cocona.CoconaApp.Run(Arguments); - - public static void Main() => - BenchmarkRunner.Run(DefaultConfig.Instance.With(ConfigOptions.DisableOptimizationsValidator)); + public static void Main() => BenchmarkRunner.Run( + DefaultConfig + .Instance + .With(ConfigOptions.DisableOptimizationsValidator) + ); } } \ No newline at end of file diff --git a/CliFx.Benchmarks/CliFx.Benchmarks.csproj b/CliFx.Benchmarks/CliFx.Benchmarks.csproj index 941e403..57e8df3 100644 --- a/CliFx.Benchmarks/CliFx.Benchmarks.csproj +++ b/CliFx.Benchmarks/CliFx.Benchmarks.csproj @@ -10,9 +10,9 @@ - + - + diff --git a/CliFx.Benchmarks/Commands/CliFxCommand.cs b/CliFx.Benchmarks/Commands/CliFxCommand.cs deleted file mode 100644 index f543bbf..0000000 --- a/CliFx.Benchmarks/Commands/CliFxCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Benchmarks.Commands -{ - [Command] - public class CliFxCommand : ICommand - { - [CommandOption("str", 's')] - public string? StrOption { get; set; } - - [CommandOption("int", 'i')] - public int IntOption { get; set; } - - [CommandOption("bool", 'b')] - public bool BoolOption { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/CliprCommand.cs b/CliFx.Benchmarks/Commands/CliprCommand.cs deleted file mode 100644 index 88c5507..0000000 --- a/CliFx.Benchmarks/Commands/CliprCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using clipr; - -namespace CliFx.Benchmarks.Commands -{ - public class CliprCommand - { - [NamedArgument('s', "str")] - public string? StrOption { get; set; } - - [NamedArgument('i', "int")] - public int IntOption { get; set; } - - [NamedArgument('b', "bool", Constraint = NumArgsConstraint.Optional, Const = true)] - public bool BoolOption { get; set; } - - public void Execute() - { - } - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/CoconaCommand.cs b/CliFx.Benchmarks/Commands/CoconaCommand.cs deleted file mode 100644 index 65deaef..0000000 --- a/CliFx.Benchmarks/Commands/CoconaCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Cocona; - -namespace CliFx.Benchmarks.Commands -{ - public class CoconaCommand - { - public void Execute( - [Option("str", new []{'s'})] - string? strOption, - [Option("int", new []{'i'})] - int intOption, - [Option("bool", new []{'b'})] - bool boolOption) - { - } - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/CommandLineParserCommand.cs b/CliFx.Benchmarks/Commands/CommandLineParserCommand.cs deleted file mode 100644 index 9a91f12..0000000 --- a/CliFx.Benchmarks/Commands/CommandLineParserCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CommandLine; - -namespace CliFx.Benchmarks.Commands -{ - public class CommandLineParserCommand - { - [Option('s', "str")] - public string? StrOption { get; set; } - - [Option('i', "int")] - public int IntOption { get; set; } - - [Option('b', "bool")] - public bool BoolOption { get; set; } - - public void Execute() - { - } - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/McMasterCommand.cs b/CliFx.Benchmarks/Commands/McMasterCommand.cs deleted file mode 100644 index 6501290..0000000 --- a/CliFx.Benchmarks/Commands/McMasterCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using McMaster.Extensions.CommandLineUtils; - -namespace CliFx.Benchmarks.Commands -{ - public class McMasterCommand - { - [Option("--str|-s")] - public string? StrOption { get; set; } - - [Option("--int|-i")] - public int IntOption { get; set; } - - [Option("--bool|-b")] - public bool BoolOption { get; set; } - - public int OnExecute() => 0; - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/PowerArgsCommand.cs b/CliFx.Benchmarks/Commands/PowerArgsCommand.cs deleted file mode 100644 index 2c09e30..0000000 --- a/CliFx.Benchmarks/Commands/PowerArgsCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using PowerArgs; - -namespace CliFx.Benchmarks.Commands -{ - public class PowerArgsCommand - { - [ArgShortcut("--str"), ArgShortcut("-s")] - public string? StrOption { get; set; } - - [ArgShortcut("--int"), ArgShortcut("-i")] - public int IntOption { get; set; } - - [ArgShortcut("--bool"), ArgShortcut("-b")] - public bool BoolOption { get; set; } - - public void Main() - { - } - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Commands/SystemCommandLineCommand.cs b/CliFx.Benchmarks/Commands/SystemCommandLineCommand.cs deleted file mode 100644 index 9a655d0..0000000 --- a/CliFx.Benchmarks/Commands/SystemCommandLineCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Invocation; -using System.Threading.Tasks; - -namespace CliFx.Benchmarks.Commands -{ - public class SystemCommandLineCommand - { - public static int ExecuteHandler(string s, int i, bool b) => 0; - - public Task ExecuteAsync(string[] args) - { - var command = new RootCommand - { - new Option(new[] {"--str", "-s"}) - { - Argument = new Argument() - }, - new Option(new[] {"--int", "-i"}) - { - Argument = new Argument() - }, - new Option(new[] {"--bool", "-b"}) - { - Argument = new Argument() - } - }; - - command.Handler = CommandHandler.Create(typeof(SystemCommandLineCommand).GetMethod(nameof(ExecuteHandler))); - - return command.InvokeAsync(args); - } - } -} \ No newline at end of file diff --git a/CliFx.Benchmarks/Readme.md b/CliFx.Benchmarks/Readme.md new file mode 100644 index 0000000..fd85cff --- /dev/null +++ b/CliFx.Benchmarks/Readme.md @@ -0,0 +1,22 @@ +## CliFx.Benchmarks + +All benchmarks below were ran with the following configuration: + +```ini +BenchmarkDotNet=v0.12.0, OS=Windows 10.0.14393.3443 (1607/AnniversaryUpdate/Redstone1) +Intel Core i5-4460 CPU 3.20GHz (Haswell), 1 CPU, 4 logical and 4 physical cores +Frequency=3124994 Hz, Resolution=320.0006 ns, Timer=TSC +.NET Core SDK=3.1.100 + [Host] : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT + DefaultJob : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT +``` + +| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | +| ------------------------------------ | ----------: | --------: | ---------: | ----: | ------: | ---: | +| CommandLineParser | 24.79 us | 0.166 us | 0.155 us | 0.49 | 0.00 | 1 | +| CliFx | 50.27 us | 0.248 us | 0.232 us | 1.00 | 0.00 | 2 | +| Clipr | 160.22 us | 0.817 us | 0.764 us | 3.19 | 0.02 | 3 | +| McMaster.Extensions.CommandLineUtils | 166.45 us | 1.111 us | 1.039 us | 3.31 | 0.03 | 4 | +| System.CommandLine | 170.27 us | 0.599 us | 0.560 us | 3.39 | 0.02 | 5 | +| PowerArgs | 306.12 us | 1.495 us | 1.398 us | 6.09 | 0.03 | 6 | +| Cocona | 1,856.07 us | 48.727 us | 141.367 us | 37.88 | 2.60 | 7 | diff --git a/CliFx.Demo/Commands/BookAddCommand.cs b/CliFx.Demo/Commands/BookAddCommand.cs index de4522d..b552978 100644 --- a/CliFx.Demo/Commands/BookAddCommand.cs +++ b/CliFx.Demo/Commands/BookAddCommand.cs @@ -1,45 +1,45 @@ using System; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Demo.Internal; -using CliFx.Demo.Models; -using CliFx.Demo.Services; +using CliFx.Demo.Domain; +using CliFx.Demo.Utils; using CliFx.Exceptions; +using CliFx.Infrastructure; namespace CliFx.Demo.Commands { [Command("book add", Description = "Add a book to the library.")] public partial class BookAddCommand : ICommand { - private readonly LibraryService _libraryService; + private readonly LibraryProvider _libraryProvider; [CommandParameter(0, Name = "title", Description = "Book title.")] - public string Title { get; set; } = ""; + public string Title { get; init; } = ""; [CommandOption("author", 'a', IsRequired = true, Description = "Book author.")] - public string Author { get; set; } = ""; + public string Author { get; init; } = ""; [CommandOption("published", 'p', Description = "Book publish date.")] - public DateTimeOffset Published { get; set; } = CreateRandomDate(); + public DateTimeOffset Published { get; init; } = CreateRandomDate(); [CommandOption("isbn", 'n', Description = "Book ISBN.")] - public Isbn Isbn { get; set; } = CreateRandomIsbn(); + public Isbn Isbn { get; init; } = CreateRandomIsbn(); - public BookAddCommand(LibraryService libraryService) + public BookAddCommand(LibraryProvider libraryProvider) { - _libraryService = libraryService; + _libraryProvider = libraryProvider; } public ValueTask ExecuteAsync(IConsole console) { - if (_libraryService.GetBook(Title) != null) - throw new CommandException("Book already exists.", 1); + if (_libraryProvider.TryGetBook(Title) is not null) + throw new CommandException("Book already exists.", 10); var book = new Book(Title, Author, Published, Isbn); - _libraryService.AddBook(book); + _libraryProvider.AddBook(book); console.Output.WriteLine("Book added."); - console.RenderBook(book); + console.Output.WriteBook(book); return default; } @@ -56,13 +56,15 @@ namespace CliFx.Demo.Commands Random.Next(1, 23), Random.Next(1, 59), Random.Next(1, 59), - TimeSpan.Zero); + TimeSpan.Zero + ); private static Isbn CreateRandomIsbn() => new( Random.Next(0, 999), Random.Next(0, 99), Random.Next(0, 99999), Random.Next(0, 99), - Random.Next(0, 9)); + Random.Next(0, 9) + ); } } \ No newline at end of file diff --git a/CliFx.Demo/Commands/BookCommand.cs b/CliFx.Demo/Commands/BookCommand.cs index 1f9954a..59869a7 100644 --- a/CliFx.Demo/Commands/BookCommand.cs +++ b/CliFx.Demo/Commands/BookCommand.cs @@ -1,32 +1,33 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Demo.Internal; -using CliFx.Demo.Services; +using CliFx.Demo.Domain; +using CliFx.Demo.Utils; using CliFx.Exceptions; +using CliFx.Infrastructure; namespace CliFx.Demo.Commands { - [Command("book", Description = "View, list, add or remove books.")] + [Command("book", Description = "Retrieve a book from the library.")] public class BookCommand : ICommand { - private readonly LibraryService _libraryService; + private readonly LibraryProvider _libraryProvider; - [CommandParameter(0, Name = "title", Description = "Book title.")] - public string Title { get; set; } = ""; + [CommandParameter(0, Name = "title", Description = "Title of the book to retrieve.")] + public string Title { get; init; } = ""; - public BookCommand(LibraryService libraryService) + public BookCommand(LibraryProvider libraryProvider) { - _libraryService = libraryService; + _libraryProvider = libraryProvider; } public ValueTask ExecuteAsync(IConsole console) { - var book = _libraryService.GetBook(Title); + var book = _libraryProvider.TryGetBook(Title); - if (book == null) - throw new CommandException("Book not found.", 1); + if (book is null) + throw new CommandException("Book not found.", 10); - console.RenderBook(book); + console.Output.WriteBook(book); return default; } diff --git a/CliFx.Demo/Commands/BookListCommand.cs b/CliFx.Demo/Commands/BookListCommand.cs index 9ccfbfb..61ea1db 100644 --- a/CliFx.Demo/Commands/BookListCommand.cs +++ b/CliFx.Demo/Commands/BookListCommand.cs @@ -1,34 +1,34 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Demo.Internal; -using CliFx.Demo.Services; +using CliFx.Demo.Domain; +using CliFx.Demo.Utils; +using CliFx.Infrastructure; namespace CliFx.Demo.Commands { [Command("book list", Description = "List all books in the library.")] public class BookListCommand : ICommand { - private readonly LibraryService _libraryService; + private readonly LibraryProvider _libraryProvider; - public BookListCommand(LibraryService libraryService) + public BookListCommand(LibraryProvider libraryProvider) { - _libraryService = libraryService; + _libraryProvider = libraryProvider; } public ValueTask ExecuteAsync(IConsole console) { - var library = _libraryService.GetLibrary(); + var library = _libraryProvider.GetLibrary(); - var isFirst = true; - foreach (var book in library.Books) + for (var i = 0; i < library.Books.Count; i++) { - // Margin - if (!isFirst) + // Add margin + if (i != 0) console.Output.WriteLine(); - isFirst = false; // Render book - console.RenderBook(book); + var book = library.Books[i]; + console.Output.WriteBook(book); } return default; diff --git a/CliFx.Demo/Commands/BookRemoveCommand.cs b/CliFx.Demo/Commands/BookRemoveCommand.cs index 85ba936..30657c5 100644 --- a/CliFx.Demo/Commands/BookRemoveCommand.cs +++ b/CliFx.Demo/Commands/BookRemoveCommand.cs @@ -1,31 +1,32 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Demo.Services; +using CliFx.Demo.Domain; using CliFx.Exceptions; +using CliFx.Infrastructure; namespace CliFx.Demo.Commands { [Command("book remove", Description = "Remove a book from the library.")] public class BookRemoveCommand : ICommand { - private readonly LibraryService _libraryService; + private readonly LibraryProvider _libraryProvider; - [CommandParameter(0, Name = "title", Description = "Book title.")] - public string Title { get; set; } = ""; + [CommandParameter(0, Name = "title", Description = "Title of the book to remove.")] + public string Title { get; init; } = ""; - public BookRemoveCommand(LibraryService libraryService) + public BookRemoveCommand(LibraryProvider libraryProvider) { - _libraryService = libraryService; + _libraryProvider = libraryProvider; } public ValueTask ExecuteAsync(IConsole console) { - var book = _libraryService.GetBook(Title); + var book = _libraryProvider.TryGetBook(Title); - if (book == null) - throw new CommandException("Book not found.", 1); + if (book is null) + throw new CommandException("Book not found.", 10); - _libraryService.RemoveBook(book); + _libraryProvider.RemoveBook(book); console.Output.WriteLine($"Book {Title} removed."); diff --git a/CliFx.Demo/Models/Book.cs b/CliFx.Demo/Domain/Book.cs similarity index 94% rename from CliFx.Demo/Models/Book.cs rename to CliFx.Demo/Domain/Book.cs index 43cbcd1..b24ec7f 100644 --- a/CliFx.Demo/Models/Book.cs +++ b/CliFx.Demo/Domain/Book.cs @@ -1,6 +1,6 @@ using System; -namespace CliFx.Demo.Models +namespace CliFx.Demo.Domain { public class Book { diff --git a/CliFx.Demo/Models/Isbn.cs b/CliFx.Demo/Domain/Isbn.cs similarity index 97% rename from CliFx.Demo/Models/Isbn.cs rename to CliFx.Demo/Domain/Isbn.cs index c17b8ff..5266dfe 100644 --- a/CliFx.Demo/Models/Isbn.cs +++ b/CliFx.Demo/Domain/Isbn.cs @@ -1,6 +1,6 @@ using System; -namespace CliFx.Demo.Models +namespace CliFx.Demo.Domain { public partial class Isbn { diff --git a/CliFx.Demo/Domain/Library.cs b/CliFx.Demo/Domain/Library.cs new file mode 100644 index 0000000..cebe0e8 --- /dev/null +++ b/CliFx.Demo/Domain/Library.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CliFx.Demo.Domain +{ + public partial class Library + { + public IReadOnlyList Books { get; } + + public Library(IReadOnlyList books) + { + Books = books; + } + + public Library WithBook(Book book) + { + var books = Books.ToList(); + books.Add(book); + + return new Library(books); + } + + public Library WithoutBook(Book book) + { + var books = Books.Where(b => b != book).ToArray(); + + return new Library(books); + } + } + + public partial class Library + { + public static Library Empty { get; } = new(Array.Empty()); + } +} \ No newline at end of file diff --git a/CliFx.Demo/Services/LibraryService.cs b/CliFx.Demo/Domain/LibraryProvider.cs similarity index 74% rename from CliFx.Demo/Services/LibraryService.cs rename to CliFx.Demo/Domain/LibraryProvider.cs index 2d86e05..6e9e79b 100644 --- a/CliFx.Demo/Services/LibraryService.cs +++ b/CliFx.Demo/Domain/LibraryProvider.cs @@ -1,13 +1,12 @@ using System.IO; using System.Linq; -using CliFx.Demo.Models; using Newtonsoft.Json; -namespace CliFx.Demo.Services +namespace CliFx.Demo.Domain { - public class LibraryService + public class LibraryProvider { - private string StorageFilePath => Path.Combine(Directory.GetCurrentDirectory(), "Data.json"); + private static string StorageFilePath { get; } = Path.Combine(Directory.GetCurrentDirectory(), "Library.json"); private void StoreLibrary(Library library) { @@ -25,7 +24,7 @@ namespace CliFx.Demo.Services return JsonConvert.DeserializeObject(data); } - public Book? GetBook(string title) => GetLibrary().Books.FirstOrDefault(b => b.Title == title); + public Book? TryGetBook(string title) => GetLibrary().Books.FirstOrDefault(b => b.Title == title); public void AddBook(Book book) { diff --git a/CliFx.Demo/Internal/Extensions.cs b/CliFx.Demo/Internal/Extensions.cs deleted file mode 100644 index 2ec9e10..0000000 --- a/CliFx.Demo/Internal/Extensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using CliFx.Demo.Models; - -namespace CliFx.Demo.Internal -{ - internal static class Extensions - { - public static void RenderBook(this IConsole console, Book book) - { - // Title - console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Title)); - - // Author - console.Output.Write(" "); - console.Output.Write("Author: "); - console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Author)); - - // Published - console.Output.Write(" "); - console.Output.Write("Published: "); - console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine($"{book.Published:d}")); - - // ISBN - console.Output.Write(" "); - console.Output.Write("ISBN: "); - console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Isbn)); - } - } -} \ No newline at end of file diff --git a/CliFx.Demo/Models/Extensions.cs b/CliFx.Demo/Models/Extensions.cs deleted file mode 100644 index 5b97758..0000000 --- a/CliFx.Demo/Models/Extensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Linq; - -namespace CliFx.Demo.Models -{ - public static class Extensions - { - public static Library WithBook(this Library library, Book book) - { - var books = library.Books.ToList(); - books.Add(book); - - return new Library(books); - } - - public static Library WithoutBook(this Library library, Book book) - { - var books = library.Books.Where(b => b != book).ToArray(); - - return new Library(books); - } - } -} \ No newline at end of file diff --git a/CliFx.Demo/Models/Library.cs b/CliFx.Demo/Models/Library.cs deleted file mode 100644 index 28b325d..0000000 --- a/CliFx.Demo/Models/Library.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace CliFx.Demo.Models -{ - public partial class Library - { - public IReadOnlyList Books { get; } - - public Library(IReadOnlyList books) - { - Books = books; - } - } - - public partial class Library - { - public static Library Empty { get; } = new(Array.Empty()); - } -} \ No newline at end of file diff --git a/CliFx.Demo/Program.cs b/CliFx.Demo/Program.cs index 8c3dbd7..9773537 100644 --- a/CliFx.Demo/Program.cs +++ b/CliFx.Demo/Program.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; using CliFx.Demo.Commands; -using CliFx.Demo.Services; +using CliFx.Demo.Domain; using Microsoft.Extensions.DependencyInjection; namespace CliFx.Demo @@ -14,7 +14,7 @@ namespace CliFx.Demo var services = new ServiceCollection(); // Register services - services.AddSingleton(); + services.AddSingleton(); // Register commands services.AddTransient(); @@ -27,6 +27,7 @@ namespace CliFx.Demo public static async Task Main() => await new CliApplicationBuilder() + .SetDescription("Demo application showcasing CliFx features.") .AddCommandsFromThisAssembly() .UseTypeActivator(GetServiceProvider().GetRequiredService) .Build() diff --git a/CliFx.Demo/Readme.md b/CliFx.Demo/Readme.md index b2b8d54..a75638a 100644 --- a/CliFx.Demo/Readme.md +++ b/CliFx.Demo/Readme.md @@ -2,6 +2,4 @@ Sample command line interface for managing a library of books. -This demo project shows basic CliFx functionality such as command routing, argument parsing, autogenerated help text, and some other things. - -You can get a list of available commands by running `CliFx.Demo --help`. \ No newline at end of file +This demo project showcases basic CliFx functionality such as command routing, argument parsing, autogenerated help text. \ No newline at end of file diff --git a/CliFx.Demo/Utils/ConsoleExtensions.cs b/CliFx.Demo/Utils/ConsoleExtensions.cs new file mode 100644 index 0000000..b3ce89a --- /dev/null +++ b/CliFx.Demo/Utils/ConsoleExtensions.cs @@ -0,0 +1,37 @@ +using System; +using CliFx.Demo.Domain; +using CliFx.Infrastructure; + +namespace CliFx.Demo.Utils +{ + internal static class ConsoleExtensions + { + public static void WriteBook(this ConsoleWriter writer, Book book) + { + // Title + using (writer.Console.WithForegroundColor(ConsoleColor.White)) + writer.WriteLine(book.Title); + + // Author + writer.Write(" "); + writer.Write("Author: "); + + using (writer.Console.WithForegroundColor(ConsoleColor.White)) + writer.WriteLine(book.Author); + + // Published + writer.Write(" "); + writer.Write("Published: "); + + using (writer.Console.WithForegroundColor(ConsoleColor.White)) + writer.WriteLine($"{book.Published:d}"); + + // ISBN + writer.Write(" "); + writer.Write("ISBN: "); + + using (writer.Console.WithForegroundColor(ConsoleColor.White)) + writer.WriteLine(book.Isbn); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests.Dummy/Commands/ConsoleTestCommand.cs b/CliFx.Tests.Dummy/Commands/ConsoleTestCommand.cs index 90c561b..f81f8bc 100644 --- a/CliFx.Tests.Dummy/Commands/ConsoleTestCommand.cs +++ b/CliFx.Tests.Dummy/Commands/ConsoleTestCommand.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using CliFx.Attributes; +using CliFx.Infrastructure; namespace CliFx.Tests.Dummy.Commands { @@ -11,11 +12,11 @@ namespace CliFx.Tests.Dummy.Commands { var input = console.Input.ReadToEnd(); - console.WithColors(ConsoleColor.Black, ConsoleColor.White, () => + using (console.WithColors(ConsoleColor.Black, ConsoleColor.White)) { console.Output.WriteLine(input); console.Error.WriteLine(input); - }); + } return default; } diff --git a/CliFx.Tests.Dummy/Commands/EnvironmentTestCommand.cs b/CliFx.Tests.Dummy/Commands/EnvironmentTestCommand.cs new file mode 100644 index 0000000..cf5f4b4 --- /dev/null +++ b/CliFx.Tests.Dummy/Commands/EnvironmentTestCommand.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace CliFx.Tests.Dummy.Commands +{ + [Command("env-test")] + public class EnvironmentTestCommand : ICommand + { + [CommandOption("target", EnvironmentVariable = "ENV_TARGET")] + public string GreetingTarget { get; set; } = "World"; + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine($"Hello {GreetingTarget}!"); + + return default; + } + } +} \ No newline at end of file diff --git a/CliFx.Tests.Dummy/Commands/HelloWorldCommand.cs b/CliFx.Tests.Dummy/Commands/HelloWorldCommand.cs deleted file mode 100644 index 77e8b66..0000000 --- a/CliFx.Tests.Dummy/Commands/HelloWorldCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Dummy.Commands -{ - [Command] - public class HelloWorldCommand : ICommand - { - [CommandOption("target", EnvironmentVariableName = "ENV_TARGET")] - public string Target { get; set; } = "World"; - - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine($"Hello {Target}!"); - - return default; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests.Dummy/Program.cs b/CliFx.Tests.Dummy/Program.cs index 5218a77..740078e 100644 --- a/CliFx.Tests.Dummy/Program.cs +++ b/CliFx.Tests.Dummy/Program.cs @@ -3,6 +3,9 @@ using System.Threading.Tasks; namespace CliFx.Tests.Dummy { + // This dummy application is used in tests for scenarios + // that require an external process to properly verify. + public static partial class Program { public static Assembly Assembly { get; } = typeof(Program).Assembly; diff --git a/CliFx.Tests/ApplicationSpecs.cs b/CliFx.Tests/ApplicationSpecs.cs index 619dce1..ad469fc 100644 --- a/CliFx.Tests/ApplicationSpecs.cs +++ b/CliFx.Tests/ApplicationSpecs.cs @@ -1,495 +1,86 @@ using System; -using System.IO; +using System.Collections.Generic; using System.Threading.Tasks; -using CliFx.Tests.Commands; -using CliFx.Tests.Commands.Invalid; +using CliFx.Tests.Utils; using FluentAssertions; using Xunit; using Xunit.Abstractions; namespace CliFx.Tests { - public class ApplicationSpecs + public class ApplicationSpecs : SpecsBase { - private readonly ITestOutputHelper _output; - - public ApplicationSpecs(ITestOutputHelper output) => _output = output; + public ApplicationSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } [Fact] - public void Application_can_be_created_with_a_default_configuration() + public async Task Application_can_be_created_with_minimal_configuration() { // Act var app = new CliApplicationBuilder() .AddCommandsFromThisAssembly() + .UseConsole(FakeConsole) .Build(); + var exitCode = await app.RunAsync( + Array.Empty(), + new Dictionary() + ); + // Assert - app.Should().NotBeNull(); + exitCode.Should().Be(0); } [Fact] - public void Application_can_be_created_with_a_custom_configuration() + public async Task Application_can_be_created_with_a_fully_customized_configuration() { // Act var app = new CliApplicationBuilder() - .AddCommand() - .AddCommandsFrom(typeof(DefaultCommand).Assembly) - .AddCommands(new[] {typeof(DefaultCommand)}) - .AddCommandsFrom(new[] {typeof(DefaultCommand).Assembly}) + .AddCommand() + .AddCommandsFrom(typeof(NoOpCommand).Assembly) + .AddCommands(new[] {typeof(NoOpCommand)}) + .AddCommandsFrom(new[] {typeof(NoOpCommand).Assembly}) .AddCommandsFromThisAssembly() .AllowDebugMode() .AllowPreviewMode() - .UseTitle("test") - .UseExecutableName("test") - .UseVersionText("test") - .UseDescription("test") - .UseConsole(new VirtualConsole(Stream.Null)) + .SetTitle("test") + .SetExecutableName("test") + .SetVersion("test") + .SetDescription("test") + .UseConsole(FakeConsole) .UseTypeActivator(Activator.CreateInstance!) .Build(); + var exitCode = await app.RunAsync( + Array.Empty(), + new Dictionary() + ); + // Assert - app.Should().NotBeNull(); + exitCode.Should().Be(0); } [Fact] - public async Task At_least_one_command_must_be_defined_in_an_application() + public async Task Application_configuration_fails_if_an_invalid_command_is_registered() { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .UseConsole(console) + // Act + var app = new CliApplicationBuilder() + .AddCommand(typeof(ApplicationSpecs)) + .UseConsole(FakeConsole) .Build(); - // Act - var exitCode = await application.RunAsync(Array.Empty()); + var exitCode = await app.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); // Assert exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Commands_must_implement_the_corresponding_interface() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand(typeof(NonImplementedCommand)) - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Commands_must_be_annotated_by_an_attribute() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Commands_must_have_unique_names() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_can_be_default_but_only_if_it_is_the_only_such_command() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameters_must_have_unique_order() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameters_must_have_unique_names() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameter_can_be_non_scalar_only_if_no_other_such_parameter_is_present() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameter_can_be_non_scalar_only_if_it_is_the_last_in_order() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameter_custom_converter_must_implement_the_corresponding_interface() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_parameter_custom_validator_must_implement_the_corresponding_interface() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_names_that_are_not_empty() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_names_that_are_longer_than_one_character() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_unique_names() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_unique_short_names() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_unique_environment_variable_names() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_not_have_conflicts_with_the_implicit_help_option() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_not_have_conflicts_with_the_implicit_version_option() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_option_custom_converter_must_implement_the_corresponding_interface() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_option_custom_validator_must_implement_the_corresponding_interface() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_names_that_start_with_a_letter_character() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_options_must_have_short_names_that_are_letter_characters() - { - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); + stdErr.Should().Contain("not a valid command"); } } } \ No newline at end of file diff --git a/CliFx.Tests/ArgumentBindingSpecs.cs b/CliFx.Tests/ArgumentBindingSpecs.cs deleted file mode 100644 index bd1012c..0000000 --- a/CliFx.Tests/ArgumentBindingSpecs.cs +++ /dev/null @@ -1,270 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Tests.Commands; -using CliFx.Tests.Internal; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace CliFx.Tests -{ - public class ArgumentBindingSpecs - { - private readonly ITestOutputHelper _output; - - public ArgumentBindingSpecs(ITestOutputHelper output) => _output = output; - - [Fact] - public async Task Property_annotated_as_an_option_can_be_bound_from_multiple_values_even_if_the_inputs_use_mixed_naming() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--opt", "foo", "-o", "bar", "--opt", "baz" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new WithStringArrayOptionCommand - { - Opt = new[] {"foo", "bar", "baz"} - }); - } - - [Fact] - public async Task Property_annotated_as_a_required_option_must_always_be_set() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--opt-a", "foo" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Property_annotated_as_a_required_option_must_always_be_bound_to_some_value() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--opt-a" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Property_annotated_as_a_required_option_must_always_be_bound_to_at_least_one_value_if_it_expects_multiple_values() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--opt-a", "foo" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Property_annotated_as_parameter_is_bound_directly_from_argument_value_according_to_the_order() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "foo", "13", "bar", "baz" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new WithParametersCommand - { - ParamA = "foo", - ParamB = 13, - ParamC = new[] {"bar", "baz"} - }); - } - - [Fact] - public async Task Property_annotated_as_parameter_must_always_be_bound_to_some_value() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Property_annotated_as_parameter_must_always_be_bound_to_at_least_one_value_if_it_expects_multiple_values() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "foo", "13" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Argument_that_begins_with_a_dash_is_not_parsed_as_option_name_if_it_does_not_start_with_a_letter_character() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int", "-13" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Int = -13 - }); - } - - [Fact] - public async Task All_provided_option_arguments_must_be_bound_to_corresponding_properties() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--non-existing-option", "13" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task All_provided_parameter_arguments_must_be_bound_to_corresponding_properties() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cnd", "non-existing-parameter" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/ArgumentConversionSpecs.cs b/CliFx.Tests/ArgumentConversionSpecs.cs deleted file mode 100644 index 097dc4c..0000000 --- a/CliFx.Tests/ArgumentConversionSpecs.cs +++ /dev/null @@ -1,1441 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Threading.Tasks; -using CliFx.Tests.Commands; -using CliFx.Tests.Internal; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace CliFx.Tests -{ - public class ArgumentConversionSpecs - { - private readonly ITestOutputHelper _output; - - public ArgumentConversionSpecs(ITestOutputHelper output) => _output = output; - - [Fact] - public async Task Argument_value_can_be_bound_to_object() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--obj", "value" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Object = "value" - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_object() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--obj-array", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - ObjectArray = new object[] {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str", "value" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - String = "value" - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-array", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringArray = new[] {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_IEnumerable_of_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-enumerable", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringEnumerable = new[] {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_IReadOnlyList_of_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-read-only-list", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringReadOnlyList = new[] {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_List_of_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-list", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringList = new List {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_HashSet_of_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-set", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringHashSet = new HashSet {"foo", "bar"} - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_boolean_as_true_if_the_value_is_true() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--bool", "true" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Bool = true - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_boolean_as_false_if_the_value_is_false() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--bool", "false" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Bool = false - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_boolean_as_true_if_the_value_is_not_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--bool" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Bool = true - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_char_if_the_value_contains_a_single_character() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--char", "a" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Char = 'a' - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_sbyte() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--sbyte", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Sbyte = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_byte() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--byte", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Byte = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_short() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--short", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Short = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_ushort() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--ushort", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Ushort = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_int() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Int = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_int_as_actual_value_if_it_is_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int-nullable", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - IntNullable = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_int_as_null_if_it_is_not_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int-nullable" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - IntNullable = null - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_int() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int-array", "3", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - IntArray = new[] {3, 15} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_nullable_of_int() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int-nullable-array", "3", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - IntNullableArray = new int?[] {3, 15} - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_uint() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--uint", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Uint = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_long() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--long", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Long = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_ulong() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--ulong", "15" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Ulong = 15 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_float() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--float", "3.14" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Float = 3.14f - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_double() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--double", "3.14" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Double = 3.14 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_decimal() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--decimal", "3.14" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Decimal = 3.14m - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_DateTime() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--datetime", "28 Apr 1995" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - DateTime = new DateTime(1995, 04, 28) - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_DateTimeOffset() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--datetime-offset", "28 Apr 1995" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - DateTimeOffset = new DateTime(1995, 04, 28) - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_TimeSpan() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--timespan", "00:14:59" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - TimeSpan = new TimeSpan(00, 14, 59) - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_TimeSpan_as_actual_value_if_it_is_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--timespan-nullable", "00:14:59" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - TimeSpanNullable = new TimeSpan(00, 14, 59) - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_TimeSpan_as_null_if_it_is_not_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--timespan-nullable" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - TimeSpanNullable = null - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_enum_type_by_name() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum", "value2" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Enum = SupportedArgumentTypesCommand.CustomEnum.Value2 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_enum_type_by_id() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum", "2" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Enum = SupportedArgumentTypesCommand.CustomEnum.Value2 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_by_name_if_it_is_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-nullable", "value3" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumNullable = SupportedArgumentTypesCommand.CustomEnum.Value3 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_by_id_if_it_is_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-nullable", "3" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumNullable = SupportedArgumentTypesCommand.CustomEnum.Value3 - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_as_null_if_it_is_not_set() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-nullable" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumNullable = null - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_names() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-array", "value1", "value3" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumArray = new[] {SupportedArgumentTypesCommand.CustomEnum.Value1, SupportedArgumentTypesCommand.CustomEnum.Value3} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_ids() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-array", "1", "3" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumArray = new[] {SupportedArgumentTypesCommand.CustomEnum.Value1, SupportedArgumentTypesCommand.CustomEnum.Value3} - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_either_names_or_ids() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--enum-array", "1", "value3" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - EnumArray = new[] {SupportedArgumentTypesCommand.CustomEnum.Value1, SupportedArgumentTypesCommand.CustomEnum.Value3} - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_constructor_accepting_a_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-constructible", "foobar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringConstructible = new SupportedArgumentTypesCommand.CustomStringConstructible("foobar") - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_custom_type_if_it_has_a_constructor_accepting_a_string() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-constructible-array", "foo", "bar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringConstructibleArray = new[] - { - new SupportedArgumentTypesCommand.CustomStringConstructible("foo"), - new SupportedArgumentTypesCommand.CustomStringConstructible("bar") - } - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_static_Parse_method() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-parseable", "foobar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringParseable = SupportedArgumentTypesCommand.CustomStringParseable.Parse("foobar") - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_static_Parse_method_with_format_provider() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--str-parseable-format", "foobar" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - StringParseableWithFormatProvider = - SupportedArgumentTypesCommand.CustomStringParseableWithFormatProvider.Parse("foobar", CultureInfo.InvariantCulture) - }); - } - - [Fact] - public async Task Argument_value_can_be_bound_to_a_custom_type_if_a_converter_has_been_specified() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--convertible", "13" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - Convertible = - (SupportedArgumentTypesCommand.CustomConvertible) - new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("13") - }); - } - - [Fact] - public async Task Argument_values_can_be_bound_to_array_of_custom_type_if_a_converter_has_been_specified() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--convertible-array", "13", "42" - }); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - - commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand - { - ConvertibleArray = new[] - { - (SupportedArgumentTypesCommand.CustomConvertible) - new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("13"), - - (SupportedArgumentTypesCommand.CustomConvertible) - new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("42") - } - }); - } - - [Fact] - public async Task Argument_value_can_only_be_bound_if_the_target_type_is_supported() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--custom" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Argument_value_can_only_be_bound_if_the_provided_value_can_be_converted_to_the_target_type() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int", "foo" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Argument_value_can_only_be_bound_to_non_nullable_type_if_it_is_set() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Argument_values_can_only_be_bound_to_a_type_that_implements_IEnumerable() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--int", "1", "2", "3" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Argument_values_can_only_be_bound_to_a_type_that_implements_IEnumerable_and_can_be_converted_from_an_array() - { - // Arrange - var (console, _, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] - { - "cmd", "--custom-enumerable" - }); - - // Assert - exitCode.Should().NotBe(0); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdErr.GetString()); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/CancellationSpecs.cs b/CliFx.Tests/CancellationSpecs.cs index b7cd1f5..4bbc166 100644 --- a/CliFx.Tests/CancellationSpecs.cs +++ b/CliFx.Tests/CancellationSpecs.cs @@ -1,36 +1,67 @@ using System; -using System.Threading; +using System.Collections.Generic; using System.Threading.Tasks; -using CliFx.Tests.Commands; +using CliFx.Tests.Utils; using FluentAssertions; using Xunit; +using Xunit.Abstractions; namespace CliFx.Tests { - public class CancellationSpecs + public class CancellationSpecs : SpecsBase { - [Fact] - public async Task Command_can_perform_additional_cleanup_if_cancellation_is_requested() + public CancellationSpecs(ITestOutputHelper testOutput) + : base(testOutput) { - // Can't test it with a real console because CliWrap can't send Ctrl+C + } + [Fact] + public async Task Command_can_register_to_receive_a_cancellation_signal_from_the_console() + { // Arrange - using var cts = new CancellationTokenSource(); - var (console, stdOut, _) = VirtualConsole.CreateBuffered(cts.Token); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + await Task.Delay( + TimeSpan.FromSeconds(3), + console.RegisterCancellationHandler() + ); + + console.Output.WriteLine(""Completed successfully""); + } + catch (OperationCanceledException) + { + console.Output.WriteLine(""Cancelled""); + throw; + } + } +}"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - cts.CancelAfter(TimeSpan.FromSeconds(0.2)); + FakeConsole.RequestCancellation(TimeSpan.FromSeconds(0.2)); - var exitCode = await application.RunAsync(new[] {"cmd"}); + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().NotBe(0); - stdOut.GetString().Trim().Should().Be(CancellableCommand.CancellationOutputText); + stdOut.Trim().Should().Be("Cancelled"); } } } \ No newline at end of file diff --git a/CliFx.Tests/CliFx.Tests.csproj b/CliFx.Tests/CliFx.Tests.csproj index 8aeaaa4..ec0cb0b 100644 --- a/CliFx.Tests/CliFx.Tests.csproj +++ b/CliFx.Tests/CliFx.Tests.csproj @@ -13,14 +13,14 @@ - + - - - + + + - + diff --git a/CliFx.Tests/Commands/CancellableCommand.cs b/CliFx.Tests/Commands/CancellableCommand.cs deleted file mode 100644 index 6eac83a..0000000 --- a/CliFx.Tests/Commands/CancellableCommand.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class CancellableCommand : ICommand - { - public const string CompletionOutputText = "Finished"; - public const string CancellationOutputText = "Canceled"; - - public async ValueTask ExecuteAsync(IConsole console) - { - try - { - await Task.Delay( - TimeSpan.FromSeconds(3), - console.GetCancellationToken() - ); - - console.Output.WriteLine(CompletionOutputText); - } - catch (OperationCanceledException) - { - console.Output.WriteLine(CancellationOutputText); - throw; - } - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/CommandExceptionCommand.cs b/CliFx.Tests/Commands/CommandExceptionCommand.cs deleted file mode 100644 index 3a1af20..0000000 --- a/CliFx.Tests/Commands/CommandExceptionCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; -using CliFx.Exceptions; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class CommandExceptionCommand : ICommand - { - [CommandOption("code", 'c')] - public int ExitCode { get; set; } = 133; - - [CommandOption("msg", 'm')] - public string? Message { get; set; } - - [CommandOption("show-help")] - public bool ShowHelp { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode, ShowHelp); - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/DefaultCommand.cs b/CliFx.Tests/Commands/DefaultCommand.cs deleted file mode 100644 index 489c79e..0000000 --- a/CliFx.Tests/Commands/DefaultCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command(Description = "Default command description")] - public class DefaultCommand : ICommand - { - public const string ExpectedOutputText = nameof(DefaultCommand); - - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine(ExpectedOutputText); - return default; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/GenericExceptionCommand.cs b/CliFx.Tests/Commands/GenericExceptionCommand.cs deleted file mode 100644 index aa8bbc1..0000000 --- a/CliFx.Tests/Commands/GenericExceptionCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class GenericExceptionCommand : ICommand - { - [CommandOption("msg", 'm')] - public string? Message { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => throw new Exception(Message); - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/GenericInnerExceptionCommand.cs b/CliFx.Tests/Commands/GenericInnerExceptionCommand.cs deleted file mode 100644 index 479f811..0000000 --- a/CliFx.Tests/Commands/GenericInnerExceptionCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class GenericInnerExceptionCommand : ICommand - { - [CommandOption("msg", 'm')] - public string? Message { get; set; } - - [CommandOption("inner-msg", 'i')] - public string? InnerMessage { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => - throw new Exception(Message, new Exception(InnerMessage)); - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/ConflictWithHelpOptionCommand.cs b/CliFx.Tests/Commands/Invalid/ConflictWithHelpOptionCommand.cs deleted file mode 100644 index 0f7f2c2..0000000 --- a/CliFx.Tests/Commands/Invalid/ConflictWithHelpOptionCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class ConflictWithHelpOptionCommand : SelfSerializeCommandBase - { - [CommandOption("option-h", 'h')] - public string? OptionH { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/ConflictWithVersionOptionCommand.cs b/CliFx.Tests/Commands/Invalid/ConflictWithVersionOptionCommand.cs deleted file mode 100644 index 4c4f7f3..0000000 --- a/CliFx.Tests/Commands/Invalid/ConflictWithVersionOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - // Must be default because version option is available only on default commands - [Command] - public class ConflictWithVersionOptionCommand : SelfSerializeCommandBase - { - [CommandOption("version")] - public string? Version { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/DuplicateOptionEnvironmentVariableNamesCommand.cs b/CliFx.Tests/Commands/Invalid/DuplicateOptionEnvironmentVariableNamesCommand.cs deleted file mode 100644 index 0618184..0000000 --- a/CliFx.Tests/Commands/Invalid/DuplicateOptionEnvironmentVariableNamesCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class DuplicateOptionEnvironmentVariableNamesCommand : SelfSerializeCommandBase - { - [CommandOption("option-a", EnvironmentVariableName = "ENV_VAR")] - public string? OptionA { get; set; } - - [CommandOption("option-b", EnvironmentVariableName = "ENV_VAR")] - public string? OptionB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/DuplicateOptionNamesCommand.cs b/CliFx.Tests/Commands/Invalid/DuplicateOptionNamesCommand.cs deleted file mode 100644 index b04d91b..0000000 --- a/CliFx.Tests/Commands/Invalid/DuplicateOptionNamesCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class DuplicateOptionNamesCommand : SelfSerializeCommandBase - { - [CommandOption("fruits")] - public string? Apples { get; set; } - - [CommandOption("fruits")] - public string? Oranges { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/DuplicateOptionShortNamesCommand.cs b/CliFx.Tests/Commands/Invalid/DuplicateOptionShortNamesCommand.cs deleted file mode 100644 index d571139..0000000 --- a/CliFx.Tests/Commands/Invalid/DuplicateOptionShortNamesCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class DuplicateOptionShortNamesCommand : SelfSerializeCommandBase - { - [CommandOption('x')] - public string? OptionA { get; set; } - - [CommandOption('x')] - public string? OptionB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/DuplicateParameterNameCommand.cs b/CliFx.Tests/Commands/Invalid/DuplicateParameterNameCommand.cs deleted file mode 100644 index 1d79e0a..0000000 --- a/CliFx.Tests/Commands/Invalid/DuplicateParameterNameCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class DuplicateParameterNameCommand : SelfSerializeCommandBase - { - [CommandParameter(0, Name = "param")] - public string? ParamA { get; set; } - - [CommandParameter(1, Name = "param")] - public string? ParamB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/DuplicateParameterOrderCommand.cs b/CliFx.Tests/Commands/Invalid/DuplicateParameterOrderCommand.cs deleted file mode 100644 index f1ab130..0000000 --- a/CliFx.Tests/Commands/Invalid/DuplicateParameterOrderCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class DuplicateParameterOrderCommand : SelfSerializeCommandBase - { - [CommandParameter(13)] - public string? ParamA { get; set; } - - [CommandParameter(13)] - public string? ParamB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/EmptyOptionNameCommand.cs b/CliFx.Tests/Commands/Invalid/EmptyOptionNameCommand.cs deleted file mode 100644 index 5f4de5d..0000000 --- a/CliFx.Tests/Commands/Invalid/EmptyOptionNameCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class EmptyOptionNameCommand : SelfSerializeCommandBase - { - [CommandOption("")] - public string? Apples { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/InvalidCustomConverterOptionCommand.cs b/CliFx.Tests/Commands/Invalid/InvalidCustomConverterOptionCommand.cs deleted file mode 100644 index 3b59e08..0000000 --- a/CliFx.Tests/Commands/Invalid/InvalidCustomConverterOptionCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class InvalidCustomConverterOptionCommand : SelfSerializeCommandBase - { - [CommandOption('f', Converter = typeof(Converter))] - public string? Option { get; set; } - - public class Converter - { - public object ConvertFrom(string value) => value; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/InvalidCustomConverterParameterCommand.cs b/CliFx.Tests/Commands/Invalid/InvalidCustomConverterParameterCommand.cs deleted file mode 100644 index 68ce67d..0000000 --- a/CliFx.Tests/Commands/Invalid/InvalidCustomConverterParameterCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class InvalidCustomConverterParameterCommand : SelfSerializeCommandBase - { - [CommandParameter(0, Converter = typeof(Converter))] - public string? Param { get; set; } - - public class Converter - { - public object ConvertFrom(string value) => value; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorOptionCommand.cs b/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorOptionCommand.cs deleted file mode 100644 index 12f007e..0000000 --- a/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorOptionCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class InvalidCustomValidatorOptionCommand : SelfSerializeCommandBase - { - [CommandOption('f', Validators = new[] { typeof(Validator) })] - public string? Option { get; set; } - - public class Validator - { - public ValidationResult Validate(string value) => ValidationResult.Ok(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorParameterCommand.cs b/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorParameterCommand.cs deleted file mode 100644 index 77a5f1d..0000000 --- a/CliFx.Tests/Commands/Invalid/InvalidCustomValidatorParameterCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class InvalidCustomValidatorParameterCommand : SelfSerializeCommandBase - { - [CommandParameter(0, Validators = new[] { typeof(Validator) })] - public string? Param { get; set; } - - public class Validator - { - public ValidationResult Validate(string value) => ValidationResult.Ok(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/MultipleNonScalarParametersCommand.cs b/CliFx.Tests/Commands/Invalid/MultipleNonScalarParametersCommand.cs deleted file mode 100644 index 5d641d0..0000000 --- a/CliFx.Tests/Commands/Invalid/MultipleNonScalarParametersCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class MultipleNonScalarParametersCommand : SelfSerializeCommandBase - { - [CommandParameter(0)] - public IReadOnlyList? ParamA { get; set; } - - [CommandParameter(1)] - public IReadOnlyList? ParamB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/NonAnnotatedCommand.cs b/CliFx.Tests/Commands/Invalid/NonAnnotatedCommand.cs deleted file mode 100644 index 8910807..0000000 --- a/CliFx.Tests/Commands/Invalid/NonAnnotatedCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CliFx.Tests.Commands.Invalid -{ - public class NonAnnotatedCommand : SelfSerializeCommandBase - { - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/NonImplementedCommand.cs b/CliFx.Tests/Commands/Invalid/NonImplementedCommand.cs deleted file mode 100644 index 72a091f..0000000 --- a/CliFx.Tests/Commands/Invalid/NonImplementedCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class NonImplementedCommand - { - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/NonLastNonScalarParameterCommand.cs b/CliFx.Tests/Commands/Invalid/NonLastNonScalarParameterCommand.cs deleted file mode 100644 index 3576d88..0000000 --- a/CliFx.Tests/Commands/Invalid/NonLastNonScalarParameterCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class NonLastNonScalarParameterCommand : SelfSerializeCommandBase - { - [CommandParameter(0)] - public IReadOnlyList? ParamA { get; set; } - - [CommandParameter(1)] - public string? ParamB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/NonLetterCharacterNameCommand.cs b/CliFx.Tests/Commands/Invalid/NonLetterCharacterNameCommand.cs deleted file mode 100644 index d254fbc..0000000 --- a/CliFx.Tests/Commands/Invalid/NonLetterCharacterNameCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class NonLetterCharacterNameCommand : SelfSerializeCommandBase - { - [CommandOption("0foo")] - public string? Apples { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/NonLetterCharacterShortNameCommand.cs b/CliFx.Tests/Commands/Invalid/NonLetterCharacterShortNameCommand.cs deleted file mode 100644 index 0367ad6..0000000 --- a/CliFx.Tests/Commands/Invalid/NonLetterCharacterShortNameCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class NonLetterCharacterShortNameCommand : SelfSerializeCommandBase - { - [CommandOption('0')] - public string? Apples { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/OtherDefaultCommand.cs b/CliFx.Tests/Commands/Invalid/OtherDefaultCommand.cs deleted file mode 100644 index d8a0ee9..0000000 --- a/CliFx.Tests/Commands/Invalid/OtherDefaultCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command] - public class OtherDefaultCommand : SelfSerializeCommandBase - { - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/Invalid/SingleCharacterOptionNameCommand.cs b/CliFx.Tests/Commands/Invalid/SingleCharacterOptionNameCommand.cs deleted file mode 100644 index f4b5117..0000000 --- a/CliFx.Tests/Commands/Invalid/SingleCharacterOptionNameCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands.Invalid -{ - [Command("cmd")] - public class SingleCharacterOptionNameCommand : SelfSerializeCommandBase - { - [CommandOption("a")] - public string? Apples { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/NamedCommand.cs b/CliFx.Tests/Commands/NamedCommand.cs deleted file mode 100644 index d50f225..0000000 --- a/CliFx.Tests/Commands/NamedCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("named", Description = "Named command description")] - public class NamedCommand : ICommand - { - public const string ExpectedOutputText = nameof(NamedCommand); - - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine(ExpectedOutputText); - return default; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/NamedSubCommand.cs b/CliFx.Tests/Commands/NamedSubCommand.cs deleted file mode 100644 index abc798c..0000000 --- a/CliFx.Tests/Commands/NamedSubCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("named sub", Description = "Named sub command description")] - public class NamedSubCommand : ICommand - { - public const string ExpectedOutputText = nameof(NamedSubCommand); - - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine(ExpectedOutputText); - return default; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/SelfSerializeCommandBase.cs b/CliFx.Tests/Commands/SelfSerializeCommandBase.cs deleted file mode 100644 index c37abda..0000000 --- a/CliFx.Tests/Commands/SelfSerializeCommandBase.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading.Tasks; -using Newtonsoft.Json; - -namespace CliFx.Tests.Commands -{ - public abstract class SelfSerializeCommandBase : ICommand - { - public ValueTask ExecuteAsync(IConsole console) - { - console.Output.WriteLine(JsonConvert.SerializeObject(this)); - return default; - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/SupportedArgumentTypesCommand.cs b/CliFx.Tests/Commands/SupportedArgumentTypesCommand.cs deleted file mode 100644 index 55906ef..0000000 --- a/CliFx.Tests/Commands/SupportedArgumentTypesCommand.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using CliFx.Attributes; -using Newtonsoft.Json; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public partial class SupportedArgumentTypesCommand : SelfSerializeCommandBase - { - [CommandOption("obj")] - public object? Object { get; set; } = 42; - - [CommandOption("str")] - public string? String { get; set; } = "foo bar"; - - [CommandOption("bool")] - public bool Bool { get; set; } - - [CommandOption("char")] - public char Char { get; set; } - - [CommandOption("sbyte")] - public sbyte Sbyte { get; set; } - - [CommandOption("byte")] - public byte Byte { get; set; } - - [CommandOption("short")] - public short Short { get; set; } - - [CommandOption("ushort")] - public ushort Ushort { get; set; } - - [CommandOption("int")] - public int Int { get; set; } - - [CommandOption("uint")] - public uint Uint { get; set; } - - [CommandOption("long")] - public long Long { get; set; } - - [CommandOption("ulong")] - public ulong Ulong { get; set; } - - [CommandOption("float")] - public float Float { get; set; } - - [CommandOption("double")] - public double Double { get; set; } - - [CommandOption("decimal")] - public decimal Decimal { get; set; } - - [CommandOption("datetime")] - public DateTime DateTime { get; set; } - - [CommandOption("datetime-offset")] - public DateTimeOffset DateTimeOffset { get; set; } - - [CommandOption("timespan")] - public TimeSpan TimeSpan { get; set; } - - [CommandOption("enum")] - public CustomEnum Enum { get; set; } - - [CommandOption("int-nullable")] - public int? IntNullable { get; set; } - - [CommandOption("enum-nullable")] - public CustomEnum? EnumNullable { get; set; } - - [CommandOption("timespan-nullable")] - public TimeSpan? TimeSpanNullable { get; set; } - - [CommandOption("str-constructible")] - public CustomStringConstructible? StringConstructible { get; set; } - - [CommandOption("str-parseable")] - public CustomStringParseable? StringParseable { get; set; } - - [CommandOption("str-parseable-format")] - public CustomStringParseableWithFormatProvider? StringParseableWithFormatProvider { get; set; } - - [CommandOption("convertible", Converter = typeof(CustomConvertibleConverter))] - public CustomConvertible? Convertible { get; set; } - - [CommandOption("obj-array")] - public object[]? ObjectArray { get; set; } - - [CommandOption("str-array")] - public string[]? StringArray { get; set; } - - [CommandOption("int-array")] - public int[]? IntArray { get; set; } - - [CommandOption("enum-array")] - public CustomEnum[]? EnumArray { get; set; } - - [CommandOption("int-nullable-array")] - public int?[]? IntNullableArray { get; set; } - - [CommandOption("str-constructible-array")] - public CustomStringConstructible[]? StringConstructibleArray { get; set; } - - [CommandOption("convertible-array", Converter = typeof(CustomConvertibleConverter))] - public CustomConvertible[]? ConvertibleArray { get; set; } - - [CommandOption("str-enumerable")] - public IEnumerable? StringEnumerable { get; set; } - - [CommandOption("str-read-only-list")] - public IReadOnlyList? StringReadOnlyList { get; set; } - - [CommandOption("str-list")] - public List? StringList { get; set; } - - [CommandOption("str-set")] - public HashSet? StringHashSet { get; set; } - } - - public partial class SupportedArgumentTypesCommand - { - public enum CustomEnum - { - Value1 = 1, - Value2 = 2, - Value3 = 3 - } - - public class CustomStringConstructible - { - public string Value { get; } - - public CustomStringConstructible(string value) => Value = value; - } - - public class CustomStringParseable - { - public string Value { get; } - - [JsonConstructor] - private CustomStringParseable(string value) => Value = value; - - public static CustomStringParseable Parse(string value) => new(value); - } - - public class CustomStringParseableWithFormatProvider - { - public string Value { get; } - - [JsonConstructor] - private CustomStringParseableWithFormatProvider(string value) => Value = value; - - public static CustomStringParseableWithFormatProvider Parse(string value, IFormatProvider formatProvider) => - new(value + " " + formatProvider); - } - - public class CustomConvertible - { - public int Value { get; } - - public CustomConvertible(int value) => Value = value; - } - - public class CustomConvertibleConverter : ArgumentValueConverter - { - public override CustomConvertible ConvertFrom(string value) => - new(int.Parse(value, CultureInfo.InvariantCulture)); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/UnsupportedArgumentTypesCommand.cs b/CliFx.Tests/Commands/UnsupportedArgumentTypesCommand.cs deleted file mode 100644 index f6efda9..0000000 --- a/CliFx.Tests/Commands/UnsupportedArgumentTypesCommand.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public partial class UnsupportedArgumentTypesCommand : SelfSerializeCommandBase - { - [CommandOption("custom")] - public CustomType? CustomNonConvertible { get; set; } - - [CommandOption("custom-enumerable")] - public CustomEnumerable? CustomEnumerableNonConvertible { get; set; } - } - - public partial class UnsupportedArgumentTypesCommand - { - public class CustomType - { - } - - public class CustomEnumerable : IEnumerable - { - public IEnumerator GetEnumerator() => ((IEnumerable) Array.Empty()).GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithDefaultValuesCommand.cs b/CliFx.Tests/Commands/WithDefaultValuesCommand.cs deleted file mode 100644 index 792febb..0000000 --- a/CliFx.Tests/Commands/WithDefaultValuesCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithDefaultValuesCommand : SelfSerializeCommandBase - { - public enum CustomEnum { Value1, Value2, Value3 }; - - [CommandOption("obj")] - public object? Object { get; set; } = 42; - - [CommandOption("str")] - public string? String { get; set; } = "foo"; - - [CommandOption("str-empty")] - public string StringEmpty { get; set; } = ""; - - [CommandOption("str-array")] - public string[]? StringArray { get; set; } = { "foo", "bar", "baz" }; - - [CommandOption("bool")] - public bool Bool { get; set; } = true; - - [CommandOption("char")] - public char Char { get; set; } = 't'; - - [CommandOption("int")] - public int Int { get; set; } = 1337; - - [CommandOption("int-nullable")] - public int? IntNullable { get; set; } = 1337; - - [CommandOption("int-array")] - public int[]? IntArray { get; set; } = { 1, 2, 3 }; - - [CommandOption("timespan")] - public TimeSpan TimeSpan { get; set; } = TimeSpan.FromMinutes(123); - - [CommandOption("enum")] - public CustomEnum Enum { get; set; } = CustomEnum.Value2; - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithDependenciesCommand.cs b/CliFx.Tests/Commands/WithDependenciesCommand.cs deleted file mode 100644 index 4e92e75..0000000 --- a/CliFx.Tests/Commands/WithDependenciesCommand.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithDependenciesCommand : ICommand - { - public class DependencyA - { - } - - public class DependencyB - { - } - - private readonly DependencyA _dependencyA; - private readonly DependencyB _dependencyB; - - public WithDependenciesCommand(DependencyA dependencyA, DependencyB dependencyB) - { - _dependencyA = dependencyA; - _dependencyB = dependencyB; - } - - public ValueTask ExecuteAsync(IConsole console) => default; - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithEnumArgumentsCommand.cs b/CliFx.Tests/Commands/WithEnumArgumentsCommand.cs deleted file mode 100644 index 26d1c5f..0000000 --- a/CliFx.Tests/Commands/WithEnumArgumentsCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithEnumArgumentsCommand : SelfSerializeCommandBase - { - public enum CustomEnum { Value1, Value2, Value3 }; - - [CommandParameter(0, Name = "enum")] - public CustomEnum EnumParameter { get; set; } - - [CommandOption("enum")] - public CustomEnum? EnumOption { get; set; } - - [CommandOption("required-enum", IsRequired = true)] - public CustomEnum RequiredEnumOption { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithEnvironmentVariablesCommand.cs b/CliFx.Tests/Commands/WithEnvironmentVariablesCommand.cs deleted file mode 100644 index 0630779..0000000 --- a/CliFx.Tests/Commands/WithEnvironmentVariablesCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithEnvironmentVariablesCommand : SelfSerializeCommandBase - { - [CommandOption("opt-a", 'a', EnvironmentVariableName = "ENV_OPT_A")] - public string? OptA { get; set; } - - [CommandOption("opt-b", 'b', EnvironmentVariableName = "ENV_OPT_B")] - public IReadOnlyList? OptB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithParametersCommand.cs b/CliFx.Tests/Commands/WithParametersCommand.cs deleted file mode 100644 index d5b064a..0000000 --- a/CliFx.Tests/Commands/WithParametersCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithParametersCommand : SelfSerializeCommandBase - { - [CommandParameter(0)] - public string? ParamA { get; set; } - - [CommandParameter(1)] - public int? ParamB { get; set; } - - [CommandParameter(2)] - public IReadOnlyList? ParamC { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithRequiredOptionsCommand.cs b/CliFx.Tests/Commands/WithRequiredOptionsCommand.cs deleted file mode 100644 index cbbce77..0000000 --- a/CliFx.Tests/Commands/WithRequiredOptionsCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithRequiredOptionsCommand : SelfSerializeCommandBase - { - [CommandOption("opt-a", 'a', IsRequired = true)] - public string? OptA { get; set; } - - [CommandOption("opt-b", 'b')] - public int? OptB { get; set; } - - [CommandOption("opt-c", 'c', IsRequired = true)] - public IReadOnlyList? OptC { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithSingleParameterCommand.cs b/CliFx.Tests/Commands/WithSingleParameterCommand.cs deleted file mode 100644 index e8ad78d..0000000 --- a/CliFx.Tests/Commands/WithSingleParameterCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithSingleParameterCommand : SelfSerializeCommandBase - { - [CommandParameter(0)] - public string? ParamA { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithSingleRequiredOptionCommand.cs b/CliFx.Tests/Commands/WithSingleRequiredOptionCommand.cs deleted file mode 100644 index 811e00f..0000000 --- a/CliFx.Tests/Commands/WithSingleRequiredOptionCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithSingleRequiredOptionCommand : SelfSerializeCommandBase - { - [CommandOption("opt-a")] - public string? OptA { get; set; } - - [CommandOption("opt-b", IsRequired = true)] - public string? OptB { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Commands/WithStringArrayOptionCommand.cs b/CliFx.Tests/Commands/WithStringArrayOptionCommand.cs deleted file mode 100644 index bede424..0000000 --- a/CliFx.Tests/Commands/WithStringArrayOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using CliFx.Attributes; - -namespace CliFx.Tests.Commands -{ - [Command("cmd")] - public class WithStringArrayOptionCommand : SelfSerializeCommandBase - { - [CommandOption("opt", 'o')] - public IReadOnlyList? Opt { get; set; } - } -} \ No newline at end of file diff --git a/CliFx.Tests/ConsoleSpecs.cs b/CliFx.Tests/ConsoleSpecs.cs index 7c5050d..7b7715a 100644 --- a/CliFx.Tests/ConsoleSpecs.cs +++ b/CliFx.Tests/ConsoleSpecs.cs @@ -1,18 +1,28 @@ using System; -using System.IO; +using System.Collections.Generic; using System.Threading.Tasks; +using CliFx.Tests.Utils; using CliWrap; using CliWrap.Buffered; using FluentAssertions; using Xunit; +using Xunit.Abstractions; namespace CliFx.Tests { - public class ConsoleSpecs + public class ConsoleSpecs : SpecsBase { - [Fact] - public async Task Real_implementation_of_console_maps_directly_to_system_console() + public ConsoleSpecs(ITestOutputHelper testOutput) + : base(testOutput) { + } + + [Fact] + public async Task Real_console_maps_directly_to_system_console() + { + // Can't verify our own console output, so using an + // external process for this test. + // Arrange var command = "Hello world" | Cli.Wrap("dotnet") .WithArguments(a => a @@ -23,53 +33,107 @@ namespace CliFx.Tests var result = await command.ExecuteBufferedAsync(); // Assert - result.StandardOutput.TrimEnd().Should().Be("Hello world"); - result.StandardError.TrimEnd().Should().Be("Hello world"); + result.StandardOutput.Trim().Should().Be("Hello world"); + result.StandardError.Trim().Should().Be("Hello world"); } [Fact] - public void Fake_implementation_of_console_can_be_used_to_execute_commands_in_isolation() + public async Task Fake_console_does_not_leak_to_system_console() { // Arrange - using var stdIn = new MemoryStream(Console.InputEncoding.GetBytes("input")); - using var stdOut = new MemoryStream(); - using var stdErr = new MemoryStream(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.ResetColor(); + console.ForegroundColor = ConsoleColor.DarkMagenta; + console.BackgroundColor = ConsoleColor.DarkMagenta; + console.CursorLeft = 42; + console.CursorTop = 24; - var console = new VirtualConsole( - input: stdIn, - output: stdOut, - error: stdErr - ); + console.Output.WriteLine(""Hello ""); + console.Error.WriteLine(""world!""); + + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); // Act - console.Output.Write("output"); - console.Error.Write("error"); - - var stdInData = console.Input.ReadToEnd(); - var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()); - var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()); - - console.ResetColor(); - console.ForegroundColor = ConsoleColor.DarkMagenta; - console.BackgroundColor = ConsoleColor.DarkMagenta; - console.CursorLeft = 42; - console.CursorTop = 24; + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); // Assert - stdInData.Should().Be("input"); - stdOutData.Should().Be("output"); - stdErrData.Should().Be("error"); + exitCode.Should().Be(0); - console.Input.Should().NotBeSameAs(Console.In); - console.Output.Should().NotBeSameAs(Console.Out); - console.Error.Should().NotBeSameAs(Console.Error); + Console.OpenStandardInput().Should().NotBe(FakeConsole.Input.BaseStream); + Console.OpenStandardOutput().Should().NotBe(FakeConsole.Output.BaseStream); + Console.OpenStandardError().Should().NotBe(FakeConsole.Error.BaseStream); - console.IsInputRedirected.Should().BeTrue(); - console.IsOutputRedirected.Should().BeTrue(); - console.IsErrorRedirected.Should().BeTrue(); + Console.ForegroundColor.Should().NotBe(ConsoleColor.DarkMagenta); + Console.BackgroundColor.Should().NotBe(ConsoleColor.DarkMagenta); - console.ForegroundColor.Should().NotBe(Console.ForegroundColor); - console.BackgroundColor.Should().NotBe(Console.BackgroundColor); + // This fails because tests don't spawn a console window + //Console.CursorLeft.Should().NotBe(42); + //Console.CursorTop.Should().NotBe(24); + + FakeConsole.IsInputRedirected.Should().BeTrue(); + FakeConsole.IsOutputRedirected.Should().BeTrue(); + FakeConsole.IsErrorRedirected.Should().BeTrue(); + } + + [Fact] + public async Task Fake_console_can_be_used_with_an_in_memory_backing_store() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + var input = console.Input.ReadToEnd(); + console.Output.WriteLine(input); + console.Error.WriteLine(input); + + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + FakeConsole.WriteInput("Hello world"); + + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("Hello world"); + stdErr.Trim().Should().Be("Hello world"); } } } \ No newline at end of file diff --git a/CliFx.Tests/ConversionSpecs.cs b/CliFx.Tests/ConversionSpecs.cs new file mode 100644 index 0000000..b58c550 --- /dev/null +++ b/CliFx.Tests/ConversionSpecs.cs @@ -0,0 +1,950 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class ConversionSpecs : SpecsBase + { + public ConversionSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_string() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "xyz"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("xyz"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_an_object() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public object Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "xyz"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("xyz"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_boolean() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public bool Foo { get; set; } + + [CommandOption('b')] + public bool Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "true", "-b", "false"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = True", + "Bar = False" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_boolean_with_implicit_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public bool Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("True"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_an_integer() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public int Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "32"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("32"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_double() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public double Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo.ToString(CultureInfo.InvariantCulture)); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "32.14"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("32.14"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_DateTimeOffset() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public DateTimeOffset Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo.ToString(""u"", CultureInfo.InvariantCulture)); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "1995-04-28Z"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("1995-04-28 00:00:00Z"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_TimeSpan() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public TimeSpan Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo.ToString(null, CultureInfo.InvariantCulture)); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "12:34:56"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("12:34:56"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_an_enum() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public enum CustomEnum { One = 1, Two = 2, Three = 3 } + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomEnum Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine((int) Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "two"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("2"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_nullable_integer() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public int? Foo { get; set; } + + [CommandOption('b')] + public int? Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-b", "123"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = ", + "Bar = 123" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_nullable_enum() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public enum CustomEnum { One = 1, Two = 2, Three = 3 } + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomEnum? Foo { get; set; } + + [CommandOption('b')] + public CustomEnum? Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + (int?) Foo); + console.Output.WriteLine(""Bar = "" + (int?) Bar); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-b", "two"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = ", + "Bar = 2" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_type_that_has_a_constructor_accepting_a_string() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class CustomType +{ + public string Value { get; } + + public CustomType(string value) => Value = value; +} + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomType Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo.Value); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "xyz"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("xyz"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_type_that_has_a_static_parse_method() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class CustomTypeA +{ + public string Value { get; } + + private CustomTypeA(string value) => Value = value; + + public static CustomTypeA Parse(string value) => + new CustomTypeA(value); +} + +public class CustomTypeB +{ + public string Value { get; } + + private CustomTypeB(string value) => Value = value; + + public static CustomTypeB Parse(string value, IFormatProvider formatProvider) => + new CustomTypeB(value); +} + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomTypeA Foo { get; set; } + + [CommandOption('b')] + public CustomTypeB Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo.Value); + console.Output.WriteLine(""Bar = "" + Bar.Value); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "hello", "-b", "world"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = hello", + "Bar = world" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_using_a_custom_converter() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class CustomConverter : BindingConverter +{ + public override int Convert(string rawValue) => + rawValue.Length; +} + +[Command] +public class Command : ICommand +{ + [CommandOption('f', Converter = typeof(CustomConverter))] + public int Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "hello world"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("11"); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_an_array_of_strings() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public string[] Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "two", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_read_only_list_of_strings() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "two", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_a_list_of_strings() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public List Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "two", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Parameter_or_option_value_can_be_converted_to_an_array_of_integers() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public int[] Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "1", "13", "27"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "1", + "13", + "27" + ); + } + + [Fact] + public async Task Parameter_or_option_value_conversion_fails_if_the_value_cannot_be_converted_to_the_target_type() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public int Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "12.34"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Parameter_or_option_value_conversion_fails_if_the_target_type_is_not_supported() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class CustomType {} + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomType Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "xyz"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("has an unsupported underlying property type"); + } + + [Fact] + public async Task Parameter_or_option_value_conversion_fails_if_the_target_non_scalar_type_is_not_supported() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class CustomType : IEnumerable +{ + public IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public CustomType Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "two"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("has an unsupported underlying property type"); + } + + [Fact] + public async Task Parameter_or_option_value_conversion_fails_if_one_of_the_validators_fail() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public class ValidatorA : BindingValidator +{ + public override BindingValidationError Validate(int value) => Ok(); +} + +public class ValidatorB : BindingValidator +{ + public override BindingValidationError Validate(int value) => Error(""Hello world""); +} + +[Command] +public class Command : ICommand +{ + [CommandOption('f', Validators = new[] {typeof(ValidatorA), typeof(ValidatorB)})] + public int Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "12"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Hello world"); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/DependencyInjectionSpecs.cs b/CliFx.Tests/DependencyInjectionSpecs.cs deleted file mode 100644 index 05d1eff..0000000 --- a/CliFx.Tests/DependencyInjectionSpecs.cs +++ /dev/null @@ -1,67 +0,0 @@ -using CliFx.Exceptions; -using CliFx.Tests.Commands; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace CliFx.Tests -{ - public class DependencyInjectionSpecs - { - private readonly ITestOutputHelper _output; - - public DependencyInjectionSpecs(ITestOutputHelper output) => _output = output; - - [Fact] - public void Default_type_activator_can_initialize_a_command_if_it_has_a_parameterless_constructor() - { - // Arrange - var activator = new DefaultTypeActivator(); - - // Act - var obj = activator.CreateInstance(typeof(DefaultCommand)); - - // Assert - obj.Should().BeOfType(); - } - - [Fact] - public void Default_type_activator_cannot_initialize_a_command_if_it_does_not_have_a_parameterless_constructor() - { - // Arrange - var activator = new DefaultTypeActivator(); - - // Act & assert - var ex = Assert.Throws(() => activator.CreateInstance(typeof(WithDependenciesCommand))); - _output.WriteLine(ex.Message); - } - - [Fact] - public void Delegate_type_activator_can_initialize_a_command_using_a_custom_function() - { - // Arrange - var activator = new DelegateTypeActivator(_ => - new WithDependenciesCommand( - new WithDependenciesCommand.DependencyA(), - new WithDependenciesCommand.DependencyB()) - ); - - // Act - var obj = activator.CreateInstance(typeof(WithDependenciesCommand)); - - // Assert - obj.Should().BeOfType(); - } - - [Fact] - public void Delegate_type_activator_throws_if_the_underlying_function_returns_null() - { - // Arrange - var activator = new DelegateTypeActivator(_ => null!); - - // Act & assert - var ex = Assert.Throws(() => activator.CreateInstance(typeof(WithDependenciesCommand))); - _output.WriteLine(ex.Message); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/DirectivesSpecs.cs b/CliFx.Tests/DirectivesSpecs.cs index c47ffeb..d4707ac 100644 --- a/CliFx.Tests/DirectivesSpecs.cs +++ b/CliFx.Tests/DirectivesSpecs.cs @@ -1,44 +1,112 @@ -using System.Collections.Generic; -using System.IO; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; using System.Threading.Tasks; -using CliFx.Tests.Commands; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using CliWrap; using FluentAssertions; using Xunit; using Xunit.Abstractions; namespace CliFx.Tests { - public class DirectivesSpecs + public class DirectivesSpecs : SpecsBase { - private readonly ITestOutputHelper _output; - - public DirectivesSpecs(ITestOutputHelper output) => _output = output; + public DirectivesSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } [Fact] - public async Task Preview_directive_can_be_specified_to_print_provided_arguments_as_they_were_parsed() + public async Task Debug_directive_can_be_specified_to_interrupt_execution_until_a_debugger_is_attached() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var stdOutBuffer = new StringBuilder(); + + var command = Cli.Wrap("dotnet") + .WithArguments(a => a + .Add(Dummy.Program.Location) + .Add("[debug]")) | stdOutBuffer; + + // Act + try + { + // This has a timeout just in case the execution hangs forever + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var task = command.ExecuteAsync(cts.Token); + + // We can't attach a debugger programmatically, so the application + // will hang indefinitely. + // To work around it, we will wait until the application writes + // something to the standard output and then kill it. + while (true) + { + if (stdOutBuffer.Length > 0) + { + cts.Cancel(); + break; + } + + await Task.Delay(100, cts.Token); + } + + await task; + } + catch (OperationCanceledException) + { + // It's expected to fail + } + + var stdOut = stdOutBuffer.ToString(); + + // Assert + stdOut.Should().Contain("Attach debugger to"); + + TestOutput.WriteLine(stdOut); + } + + [Fact] + public async Task Preview_directive_can_be_specified_to_print_command_input() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command(""cmd"")] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .AllowPreviewMode() .Build(); // Act var exitCode = await application.RunAsync( - new[] {"[preview]", "named", "param", "-abc", "--option", "foo"}, - new Dictionary() + new[] {"[preview]", "cmd", "param", "-abc", "--option", "foo"}, + new Dictionary + { + ["ENV_QOP"] = "hello", + ["ENV_KIL"] = "world" + } ); + var stdOut = FakeConsole.ReadOutputString(); + // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "named", "", "[-a]", "[-b]", "[-c]", "[--option \"foo\"]" + stdOut.Should().ContainAllInOrder( + "cmd", "", "[-a]", "[-b]", "[-c]", "[--option \"foo\"]", + "ENV_QOP", "=", "\"hello\"", + "ENV_KIL", "=", "\"world\"" ); - - _output.WriteLine(stdOut.GetString()); } } } \ No newline at end of file diff --git a/CliFx.Tests/EnvironmentSpecs.cs b/CliFx.Tests/EnvironmentSpecs.cs new file mode 100644 index 0000000..fea1f26 --- /dev/null +++ b/CliFx.Tests/EnvironmentSpecs.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using CliWrap; +using CliWrap.Buffered; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class EnvironmentSpecs : SpecsBase + { + public EnvironmentSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public async Task Option_can_fall_back_to_an_environment_variable() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", IsRequired = true, EnvironmentVariable = ""ENV_FOO"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary + { + ["ENV_FOO"] = "bar" + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("bar"); + } + + [Fact] + public async Task Option_does_not_fall_back_to_an_environment_variable_if_a_value_is_provided_through_arguments() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", EnvironmentVariable = ""ENV_FOO"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "baz"}, + new Dictionary + { + ["ENV_FOO"] = "bar" + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("baz"); + } + + [Fact] + public async Task Option_of_non_scalar_type_can_receive_multiple_values_from_an_environment_variable() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", EnvironmentVariable = ""ENV_FOO"")] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary + { + ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "bar", + "baz" + ); + } + + [Fact] + public async Task Option_of_scalar_type_always_receives_a_single_value_from_an_environment_variable() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", EnvironmentVariable = ""ENV_FOO"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary + { + ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be($"bar{Path.PathSeparator}baz"); + } + + [Fact] + public async Task Environment_variables_are_matched_case_sensitively() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", EnvironmentVariable = ""ENV_FOO"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary + { + ["ENV_foo"] = "baz", + ["ENV_FOO"] = "bar", + ["env_FOO"] = "qop" + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("bar"); + } + + [Fact] + public async Task Environment_variables_are_extracted_automatically() + { + // Ensures that the environment variables are properly obtained from + // System.Environment when they are not provided explicitly to CliApplication. + + // Arrange + var command = Cli.Wrap("dotnet") + .WithArguments(a => a + .Add(Dummy.Program.Location) + .Add("env-test")) + .WithEnvironmentVariables(e => e + .Set("ENV_TARGET", "Mars")); + + // Act + var result = await command.ExecuteBufferedAsync(); + + // Assert + result.StandardOutput.Trim().Should().Be("Hello Mars!"); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/EnvironmentVariablesSpecs.cs b/CliFx.Tests/EnvironmentVariablesSpecs.cs deleted file mode 100644 index 09f9ef6..0000000 --- a/CliFx.Tests/EnvironmentVariablesSpecs.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using CliFx.Tests.Commands; -using CliFx.Tests.Internal; -using CliWrap; -using CliWrap.Buffered; -using FluentAssertions; -using Xunit; - -namespace CliFx.Tests -{ - public class EnvironmentVariablesSpecs - { - // This test uses a real application to make sure environment variables are actually read correctly - [Fact] - public async Task Option_can_use_an_environment_variable_as_fallback() - { - // Arrange - var command = Cli.Wrap("dotnet") - .WithArguments(a => a - .Add(Dummy.Program.Location)) - .WithEnvironmentVariables(e => e - .Set("ENV_TARGET", "Mars")); - - // Act - var stdOut = await command.ExecuteBufferedAsync().Select(r => r.StandardOutput); - - // Assert - stdOut.Trim().Should().Be("Hello Mars!"); - } - - // This test uses a real application to make sure environment variables are actually read correctly - [Fact] - public async Task Option_only_uses_an_environment_variable_as_fallback_if_the_value_is_not_directly_provided() - { - // Arrange - var command = Cli.Wrap("dotnet") - .WithArguments(a => a - .Add(Dummy.Program.Location) - .Add("--target") - .Add("Jupiter")) - .WithEnvironmentVariables(e => e - .Set("ENV_TARGET", "Mars")); - - // Act - var stdOut = await command.ExecuteBufferedAsync().Select(r => r.StandardOutput); - - // Assert - stdOut.Trim().Should().Be("Hello Jupiter!"); - } - - [Fact] - public async Task Option_only_uses_an_environment_variable_as_fallback_if_the_name_matches_case_sensitively() - { - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync( - new[] {"cmd"}, - new Dictionary - { - ["ENV_opt_A"] = "incorrect", - ["ENV_OPT_A"] = "correct" - } - ); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - commandInstance.Should().BeEquivalentTo(new WithEnvironmentVariablesCommand - { - OptA = "correct" - }); - } - - [Fact] - public async Task Option_of_non_scalar_type_can_use_an_environment_variable_as_fallback_and_extract_multiple_values() - { - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync( - new[] {"cmd"}, - new Dictionary - { - ["ENV_OPT_B"] = $"foo{Path.PathSeparator}bar" - } - ); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - commandInstance.Should().BeEquivalentTo(new WithEnvironmentVariablesCommand - { - OptB = new[] {"foo", "bar"} - }); - } - - [Fact] - public async Task Option_of_scalar_type_can_use_an_environment_variable_as_fallback_regardless_of_separators() - { - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync( - new[] {"cmd"}, - new Dictionary - { - ["ENV_OPT_A"] = $"foo{Path.PathSeparator}bar" - } - ); - - var commandInstance = stdOut.GetString().DeserializeJson(); - - // Assert - exitCode.Should().Be(0); - commandInstance.Should().BeEquivalentTo(new WithEnvironmentVariablesCommand - { - OptA = $"foo{Path.PathSeparator}bar" - }); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/ErrorReportingSpecs.cs b/CliFx.Tests/ErrorReportingSpecs.cs index d07766d..ea34eb8 100644 --- a/CliFx.Tests/ErrorReportingSpecs.cs +++ b/CliFx.Tests/ErrorReportingSpecs.cs @@ -1,174 +1,205 @@ -using System.Threading.Tasks; -using CliFx.Tests.Commands; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; using FluentAssertions; using Xunit; using Xunit.Abstractions; namespace CliFx.Tests { - public class ErrorReportingSpecs + public class ErrorReportingSpecs : SpecsBase { - private readonly ITestOutputHelper _output; - - public ErrorReportingSpecs(ITestOutputHelper output) => _output = output; - - [Fact] - public async Task Command_may_throw_a_generic_exception_which_exits_and_prints_error_message_and_stack_trace() + public ErrorReportingSpecs(ITestOutputHelper testOutput) + : base(testOutput) { - // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"cmd", "-m", "Kaput"}); - - // Assert - exitCode.Should().NotBe(0); - stdOut.GetString().Should().BeEmpty(); - stdErr.GetString().Should().ContainAll( - "System.Exception:", - "Kaput", "at", - "CliFx.Tests" - ); - - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); } [Fact] - public async Task Command_may_throw_a_generic_exception_with_inner_exception_which_exits_and_prints_error_message_and_stack_trace() + public async Task Command_can_throw_an_exception_which_exits_with_a_stacktrace() { // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => + throw new Exception(""Something went wrong""); +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "-m", "Kaput", "-i", "FooBar"}); + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); // Assert exitCode.Should().NotBe(0); - stdOut.GetString().Should().BeEmpty(); - stdErr.GetString().Should().ContainAll( - "System.Exception:", - "FooBar", - "Kaput", "at", - "CliFx.Tests" + stdOut.Should().BeEmpty(); + stdErr.Should().ContainAllInOrder( + "System.Exception", "Something went wrong", + "at", "CliFx." ); - - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); } [Fact] - public async Task Command_may_throw_a_specialized_exception_which_exits_with_custom_code_and_prints_minimal_error_details() + public async Task Command_can_throw_an_exception_with_an_inner_exception_which_exits_with_a_stacktrace() { // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => + throw new Exception(""Something went wrong"", new Exception(""Another exception"")); +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "-m", "Kaput", "-c", "69"}); + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdOut.Should().BeEmpty(); + stdErr.Should().ContainAllInOrder( + "System.Exception", "Something went wrong", + "System.Exception", "Another exception", + "at", "CliFx." + ); + } + + [Fact] + public async Task Command_can_throw_a_special_exception_which_exits_with_specified_code_and_message() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException(""Something went wrong"", 69); +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); // Assert exitCode.Should().Be(69); - stdOut.GetString().Should().BeEmpty(); - stdErr.GetString().Trim().Should().Be("Kaput"); - - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); + stdOut.Should().BeEmpty(); + stdErr.Trim().Should().Be("Something went wrong"); } [Fact] - public async Task Command_may_throw_a_specialized_exception_without_error_message_which_exits_and_prints_full_error_details() + public async Task Command_can_throw_a_special_exception_without_message_which_exits_with_a_stacktrace() { // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException("""", 69); +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd"}); - - // Assert - exitCode.Should().NotBe(0); - stdOut.GetString().Should().BeEmpty(); - stdErr.GetString().Should().ContainAll( - "CliFx.Exceptions.CommandException:", - "at", - "CliFx.Tests" + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() ); - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().Be(69); + stdOut.Should().BeEmpty(); + stdErr.Should().ContainAllInOrder( + "CliFx.Exceptions.CommandException", + "at", "CliFx." + ); } [Fact] - public async Task Command_may_throw_a_specialized_exception_which_exits_and_prints_help_text() + public async Task Command_can_throw_a_special_exception_which_prints_help_text_before_exiting() { // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException(""Something went wrong"", 69, true); +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "-m", "Kaput", "--show-help"}); + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); // Assert - exitCode.Should().NotBe(0); - stdOut.GetString().Should().ContainAll( - "Usage", - "Options", - "-h|--help" - ); - stdErr.GetString().Trim().Should().Be("Kaput"); - - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); - } - - [Fact] - public async Task Command_shows_help_text_on_invalid_user_input() - { - // Arrange - var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"not-a-valid-command", "-r", "foo"}); - - // Assert - exitCode.Should().NotBe(0); - stdOut.GetString().Should().ContainAll( - "Usage", - "Options", - "-h|--help" - ); - stdErr.GetString().Should().NotBeNullOrWhiteSpace(); - - _output.WriteLine(stdOut.GetString()); - _output.WriteLine(stdErr.GetString()); + exitCode.Should().Be(69); + stdOut.Should().Contain("This will be in help text"); + stdErr.Trim().Should().Be("Something went wrong"); } } } \ No newline at end of file diff --git a/CliFx.Tests/HelpTextSpecs.cs b/CliFx.Tests/HelpTextSpecs.cs index 722eabe..3fcba11 100644 --- a/CliFx.Tests/HelpTextSpecs.cs +++ b/CliFx.Tests/HelpTextSpecs.cs @@ -1,180 +1,878 @@ -using System.Threading.Tasks; -using CliFx.Tests.Commands; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; using FluentAssertions; using Xunit; using Xunit.Abstractions; namespace CliFx.Tests { - public class HelpTextSpecs + public class HelpTextSpecs : SpecsBase { - private readonly ITestOutputHelper _output; + public HelpTextSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } - public HelpTextSpecs(ITestOutputHelper output) => _output = output; + [Fact] + public async Task Help_text_is_printed_if_no_arguments_are_provided_and_the_default_command_is_not_defined() + { + // Arrange + var application = new CliApplicationBuilder() + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Contain("This will be in help text"); + } + + [Fact] + public async Task Help_text_is_printed_if_provided_arguments_contain_the_help_option() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Contain("This will be in help text"); + } + + [Fact] + public async Task Help_text_is_printed_if_provided_arguments_contain_the_help_option_even_if_the_default_command_is_not_defined() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd child"")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Contain("This will be in help text"); + } + + [Fact] + public async Task Help_text_for_a_specific_named_command_is_printed_if_provided_arguments_match_its_name_and_contain_the_help_option() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd"", Description = ""Description of a named command."")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd child"")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"cmd", "--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Contain("Description of a named command."); + } + + [Fact] + public async Task Help_text_for_a_specific_named_child_command_is_printed_if_provided_arguments_match_its_name_and_contain_the_help_option() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd child"", Description = ""Description of a named child command."")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"cmd", "sub", "--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Contain("Description of a named child command."); + } + + [Fact] + public async Task Help_text_is_printed_on_invalid_user_input() + { + // Arrange + var application = new CliApplicationBuilder() + .AddCommand() + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"invalid-command", "--invalid-option"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdOut.Should().Contain("This will be in help text"); + stdErr.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Help_text_shows_application_metadata() + { + // Arrange + var application = new CliApplicationBuilder() + .UseConsole(FakeConsole) + .SetTitle("App title") + .SetDescription("App description") + .SetVersion("App version") + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAll( + "App title", + "App description", + "App version" + ); + } + + [Fact] + public async Task Help_text_shows_command_description() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command(Description = ""Description of the default command."")] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "DESCRIPTION", + "Description of the default command." + ); + } + + [Fact] + public async Task Help_text_shows_usage_format_which_indicates_how_to_execute_a_named_command() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "USAGE", + "[command]", "[...]" + ); + } [Fact] public async Task Help_text_shows_usage_format_which_lists_all_parameters() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + [CommandParameter(2)] + public IReadOnlyList Baz { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "--help"}); + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Usage", - "cmd", "", "", "" + stdOut.Should().ContainAllInOrder( + "USAGE", + "", "", "" ); - - _output.WriteLine(stdOut.GetString()); } [Fact] public async Task Help_text_shows_usage_format_which_lists_all_required_options() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", IsRequired = true)] + public string Foo { get; set; } + + [CommandOption(""bar"")] + public string Bar { get; set; } + + [CommandOption(""baz"", IsRequired = true)] + public IReadOnlyList Baz { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "--help"}); + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Usage", - "cmd", "--opt-a ", "--opt-c ", "[options]", - "Options", - "* -a|--opt-a", - "-b|--opt-b", - "* -c|--opt-c" + stdOut.Should().ContainAllInOrder( + "USAGE", + "--foo ", "--baz ", "[options]" ); - - _output.WriteLine(stdOut.GetString()); } [Fact] - public async Task Help_text_shows_usage_format_which_lists_available_sub_commands() + public async Task Help_text_shows_all_parameters_and_options() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0, Name = ""foo"", Description = ""Description of foo."")] + public string Foo { get; set; } + + [CommandOption(""bar"", Description = ""Description of bar."")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"--help"}); + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Usage", - "... named", - "... named sub" + stdOut.Should().ContainAllInOrder( + "PARAMETERS", + "foo", "Description of foo.", + "OPTIONS", + "--bar", "Description of bar." ); - - _output.WriteLine(stdOut.GetString()); } [Fact] - public async Task Help_text_shows_all_valid_values_for_enum_arguments() + public async Task Help_text_shows_the_implicit_help_and_version_options_on_the_default_command() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "--help"}); + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Parameters", - "enum", "Valid values: \"Value1\", \"Value2\", \"Value3\".", - "Options", - "--enum", "Valid values: \"Value1\", \"Value2\", \"Value3\".", - "* --required-enum", "Valid values: \"Value1\", \"Value2\", \"Value3\"." + stdOut.Should().ContainAllInOrder( + "OPTIONS", + "-h", "--help", "Shows help text", + "--version", "Shows version information" ); - - _output.WriteLine(stdOut.GetString()); } [Fact] - public async Task Help_text_shows_environment_variable_names_for_options_that_have_them_defined() + public async Task Help_text_shows_the_implicit_help_option_but_not_the_version_option_on_a_named_command() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command(""cmd"")] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "--help"}); + var exitCode = await application.RunAsync( + new[] {"cmd", "--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Options", - "-a|--opt-a", "Environment variable:", "ENV_OPT_A", - "-b|--opt-b", "Environment variable:", "ENV_OPT_B" + stdOut.Should().ContainAllInOrder( + "OPTIONS", + "-h", "--help", "Shows help text" + ); + stdOut.Should().NotContainAny( + "--version", "Shows version information" + ); + } + + [Fact] + public async Task Help_text_shows_all_valid_values_for_enum_parameters_and_options() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public enum CustomEnum { One, Two, Three } + +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public CustomEnum Foo { get; set; } + + [CommandOption(""bar"")] + public CustomEnum Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() ); - _output.WriteLine(stdOut.GetString()); + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "PARAMETERS", + "foo", "Choices:", "One", "Two", "Three", + "OPTIONS", + "--bar", "Choices:", "One", "Two", "Three" + ); + } + + [Fact] + public async Task Help_text_shows_environment_variables_for_options_that_have_them_configured_as_fallback() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public enum CustomEnum { One, Two, Three } + +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", EnvironmentVariable = ""ENV_FOO"")] + public CustomEnum Foo { get; set; } + + [CommandOption(""bar"", EnvironmentVariable = ""ENV_BAR"")] + public CustomEnum Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "OPTIONS", + "--foo", "Environment variable:", "ENV_FOO", + "--bar", "Environment variable:", "ENV_BAR" + ); } [Fact] public async Task Help_text_shows_default_values_for_non_required_options() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +public enum CustomEnum { One, Two, Three } + +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public object Foo { get; set; } = 42; + + [CommandOption(""bar"")] + public string Bar { get; set; } = ""hello""; + + [CommandOption(""baz"")] + public IReadOnlyList Baz { get; set; } = new[] {""one"", ""two"", ""three""}; + + [CommandOption(""qwe"")] + public bool Qwe { get; set; } = true; + + [CommandOption(""qop"")] + public int? Qop { get; set; } = 1337; + + [CommandOption(""zor"")] + public TimeSpan Zor { get; set; } = TimeSpan.FromMinutes(123); + + [CommandOption(""lol"")] + public CustomEnum Lol { get; set; } = CustomEnum.Two; + + [CommandOption(""hmm"", IsRequired = true)] + public string Hmm { get; set; } = ""not printed""; + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .UseConsole(console) + .AddCommand(commandType) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"cmd", "--help"}); + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Options", - "--obj", "Default: \"42\"", - "--str", "Default: \"foo\"", - "--str-empty", "Default: \"\"", - "--str-array", "Default: \"foo\" \"bar\" \"baz\"", - "--bool", "Default: \"True\"", - "--char", "Default: \"t\"", - "--int", "Default: \"1337\"", - "--int-nullable", "Default: \"1337\"", - "--int-array", "Default: \"1\" \"2\" \"3\"", - "--timespan", "Default: \"02:03:00\"", - "--enum", "Default: \"Value2\"" + stdOut.Should().ContainAllInOrder( + "OPTIONS", + "--foo", "Default:", "42", + "--bar", "Default:", "hello", + "--baz", "Default:", "one", "two", "three", + "--qwe", "Default:", "True", + "--qop", "Default:", "1337", + "--zor", "Default:", "02:03:00", + "--lol", "Default:", "Two" + ); + stdOut.Should().NotContain("not printed"); + } + + [Fact] + public async Task Help_text_shows_all_immediate_child_commands() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command(""cmd1"", Description = ""Description of one command."")] +public class FirstCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2"", Description = ""Description of another command."")] +public class SecondCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2 child"", Description = ""Description of another command's child command."")] +public class SecondCommandChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() ); - _output.WriteLine(stdOut.GetString()); + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "COMMANDS", + "cmd1", "Description of one command.", + "cmd2", "Description of another command." + ); + + // `cmd2 child` will still appear in the list of `cmd2` subcommands, + // but its description will not be seen. + stdOut.Should().NotContain( + "Description of another command's child command." + ); + } + + [Fact] + public async Task Help_text_shows_all_immediate_child_commands_of_each_child_command() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command(""cmd1"")] +public class FirstCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd1 child1"")] +public class FirstCommandFirstChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2"")] +public class SecondCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2 child11"")] +public class SecondCommandFirstChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2 child2"")] +public class SecondCommandSecondChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "COMMANDS", + "cmd1", "Subcommands:", "cmd1 child1", + "cmd2", "Subcommands:", "cmd2 child1", "cmd2 child2" + ); + } + + [Fact] + public async Task Help_text_shows_non_immediate_child_commands_if_they_do_not_have_a_more_specific_parent() + { + // Arrange + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command(""cmd1"")] +public class FirstCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2 child1"")] +public class SecondCommandFirstChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} + +[Command(""cmd2 child2"")] +public class SecondCommandSecondChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--help"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ContainAllInOrder( + "COMMANDS", + "cmd1", + "cmd2 child1", + "cmd2 child2" + ); + } + + [Fact] + public async Task Version_text_is_printed_if_provided_arguments_contain_the_version_option() + { + // Arrange + var application = new CliApplicationBuilder() + .AddCommand() + .SetVersion("v6.9") + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--version"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("v6.9"); } } } \ No newline at end of file diff --git a/CliFx.Tests/Internal/JsonExtensions.cs b/CliFx.Tests/Internal/JsonExtensions.cs deleted file mode 100644 index 68b636f..0000000 --- a/CliFx.Tests/Internal/JsonExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace CliFx.Tests.Internal -{ - internal static class JsonExtensions - { - public static T DeserializeJson(this string json) => - JsonConvert.DeserializeObject(json); - } -} \ No newline at end of file diff --git a/CliFx.Tests/OptionBindingSpecs.cs b/CliFx.Tests/OptionBindingSpecs.cs new file mode 100644 index 0000000..e61a894 --- /dev/null +++ b/CliFx.Tests/OptionBindingSpecs.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class OptionBindingSpecs : SpecsBase + { + public OptionBindingSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public async Task Option_is_bound_from_an_argument_matching_its_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public bool Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("True"); + } + + [Fact] + public async Task Option_is_bound_from_an_argument_matching_its_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public bool Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("True"); + } + + [Fact] + public async Task Option_is_bound_from_a_set_of_arguments_matching_its_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + [CommandOption(""bar"")] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "--bar", "two"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = one", + "Bar = two" + ); + } + + [Fact] + public async Task Option_is_bound_from_a_set_of_arguments_matching_its_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + [CommandOption('b')] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "-b", "two"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = one", + "Bar = two" + ); + } + + [Fact] + public async Task Option_is_bound_from_a_stack_of_arguments_matching_its_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public string Foo { get; set; } + + [CommandOption('b')] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-fb", "value"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = ", + "Bar = value" + ); + } + + [Fact] + public async Task Option_of_non_scalar_type_is_bound_from_a_set_of_arguments_matching_its_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""Foo"")] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "two", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Option_of_non_scalar_type_is_bound_from_a_set_of_arguments_matching_its_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "two", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Option_of_non_scalar_type_is_bound_from_multiple_sets_of_arguments_matching_its_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "--foo", "two", "--foo", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Option_of_non_scalar_type_is_bound_from_multiple_sets_of_arguments_matching_its_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption('f')] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"-f", "one", "-f", "two", "-f", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Option_of_non_scalar_type_is_bound_from_multiple_sets_of_arguments_matching_its_name_or_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", 'f')] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + foreach (var i in Foo) + console.Output.WriteLine(i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "-f", "two", "--foo", "three"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "one", + "two", + "three" + ); + } + + [Fact] + public async Task Option_is_not_bound_if_there_are_no_arguments_matching_its_name_or_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + [CommandOption(""bar"")] + public string Bar { get; set; } = ""hello""; + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = one", + "Bar = hello" + ); + } + + [Fact] + public async Task Option_binding_does_not_consider_a_negative_number_as_an_option_name_or_short_name() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(Foo); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "-13"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("-13"); + } + + [Fact] + public async Task Option_binding_fails_if_a_required_option_has_not_been_provided() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", IsRequired = true)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Missing required option(s)"); + } + + [Fact] + public async Task Option_binding_fails_if_a_required_option_has_been_provided_with_an_empty_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", IsRequired = true)] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Missing required option(s)"); + } + + [Fact] + public async Task Option_binding_fails_if_a_required_option_of_non_scalar_type_has_not_been_provided_with_at_least_one_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"", IsRequired = true)] + public IReadOnlyList Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Missing required option(s)"); + } + + [Fact] + public async Task Option_binding_fails_if_one_of_the_provided_option_names_is_not_recognized() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "--bar", "two"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Unrecognized option(s)"); + } + + [Fact] + public async Task Option_binding_fails_if_an_option_of_scalar_type_has_been_provided_with_multiple_values() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandOption(""foo"")] + public string Foo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"--foo", "one", "two", "three"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("expects a single argument, but provided with multiple"); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/ParameterBindingSpecs.cs b/CliFx.Tests/ParameterBindingSpecs.cs new file mode 100644 index 0000000..3b4a398 --- /dev/null +++ b/CliFx.Tests/ParameterBindingSpecs.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class ParameterBindingSpecs : SpecsBase + { + public ParameterBindingSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public async Task Parameter_is_bound_from_an_argument_matching_its_order() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"one", "two"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = one", + "Bar = two" + ); + } + + [Fact] + public async Task Parameter_of_non_scalar_type_is_bound_from_remaining_non_option_arguments() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + [CommandParameter(2)] + public IReadOnlyList Baz { get; set; } + + [CommandOption(""boo"")] + public string Boo { get; set; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""Foo = "" + Foo); + console.Output.WriteLine(""Bar = "" + Bar); + + foreach (var i in Baz) + console.Output.WriteLine(""Baz = "" + i); + + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"one", "two", "three", "four", "five", "--boo", "xxx"}, + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().ConsistOfLines( + "Foo = one", + "Bar = two", + "Baz = three", + "Baz = four", + "Baz = five" + ); + } + + [Fact] + public async Task Parameter_binding_fails_if_one_of_the_parameters_has_not_been_provided() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"one"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Missing parameter(s)"); + } + + [Fact] + public async Task Parameter_binding_fails_if_a_parameter_of_non_scalar_type_has_not_been_provided_with_at_least_one_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public IReadOnlyList Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"one"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Missing parameter(s)"); + } + + [Fact] + public async Task Parameter_binding_fails_if_one_of_the_provided_parameters_is_unexpected() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + [CommandParameter(0)] + public string Foo { get; set; } + + [CommandParameter(1)] + public string Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] {"one", "two", "three"}, + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Unexpected parameter(s)"); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/RoutingSpecs.cs b/CliFx.Tests/RoutingSpecs.cs index f9fd378..b989285 100644 --- a/CliFx.Tests/RoutingSpecs.cs +++ b/CliFx.Tests/RoutingSpecs.cs @@ -1,235 +1,186 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; -using CliFx.Tests.Commands; +using CliFx.Tests.Utils; using FluentAssertions; using Xunit; using Xunit.Abstractions; namespace CliFx.Tests { - public class RoutingSpecs + public class RoutingSpecs : SpecsBase { - private readonly ITestOutputHelper _output; - - public RoutingSpecs(ITestOutputHelper testOutput) => _output = testOutput; + public RoutingSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } [Fact] public async Task Default_command_is_executed_if_provided_arguments_do_not_match_any_named_command() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""default""); + return default; + } +} + +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd""); + return default; + } +} + +[Command(""cmd child"")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd child""); + return default; + } +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) + .AddCommands(commandTypes) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(Array.Empty()); + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Trim().Should().Be(DefaultCommand.ExpectedOutputText); - - _output.WriteLine(stdOut.GetString()); + stdOut.Trim().Should().Be("default"); } [Fact] public async Task Specific_named_command_is_executed_if_provided_arguments_match_its_name() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""default""); + return default; + } +} + +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd""); + return default; + } +} + +[Command(""cmd child"")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd child""); + return default; + } +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) + .AddCommands(commandTypes) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"named"}); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Trim().Should().Be(NamedCommand.ExpectedOutputText); - - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Specific_named_sub_command_is_executed_if_provided_arguments_match_its_name() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"named", "sub"}); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Trim().Should().Be(NamedSubCommand.ExpectedOutputText); - - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Help_text_is_printed_if_no_arguments_were_provided_and_default_command_is_not_defined() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .UseConsole(console) - .UseDescription("This will be visible in help") - .Build(); - - // Act - var exitCode = await application.RunAsync(Array.Empty()); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Should().Contain("This will be visible in help"); - - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Help_text_is_printed_if_provided_arguments_contain_the_help_option() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"--help"}); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Default command description", - "Usage" + var exitCode = await application.RunAsync( + new[] {"cmd"}, + new Dictionary() ); - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Help_text_is_printed_if_provided_arguments_contain_the_help_option_even_if_default_command_is_not_defined() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .UseDescription("This will be visible in help") - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"--help"}); + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().Contain("This will be visible in help"); - - _output.WriteLine(stdOut.GetString()); + stdOut.Trim().Should().Be("cmd"); } [Fact] - public async Task Help_text_for_a_specific_named_command_is_printed_if_provided_arguments_match_its_name_and_contain_the_help_option() + public async Task Specific_named_child_command_is_executed_if_provided_arguments_match_its_name() { // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); + var commandTypes = DynamicCommandBuilder.CompileMany( + // language=cs + @" +[Command] +public class DefaultCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""default""); + return default; + } +} + +[Command(""cmd"")] +public class NamedCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd""); + return default; + } +} + +[Command(""cmd child"")] +public class NamedChildCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""cmd child""); + return default; + } +} +"); var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) + .AddCommands(commandTypes) + .UseConsole(FakeConsole) .Build(); // Act - var exitCode = await application.RunAsync(new[] {"named", "--help"}); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Named command description", - "Usage", - "named" + var exitCode = await application.RunAsync( + new[] {"cmd", "child"}, + new Dictionary() ); - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Help_text_for_a_specific_named_sub_command_is_printed_if_provided_arguments_match_its_name_and_contain_the_help_option() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"named", "sub", "--help"}); + var stdOut = FakeConsole.ReadOutputString(); // Assert exitCode.Should().Be(0); - stdOut.GetString().Should().ContainAll( - "Named sub command description", - "Usage", - "named", "sub" - ); - - _output.WriteLine(stdOut.GetString()); - } - - [Fact] - public async Task Version_is_printed_if_the_only_provided_argument_is_the_version_option() - { - // Arrange - var (console, stdOut, _) = VirtualConsole.CreateBuffered(); - - var application = new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .UseVersionText("v6.9") - .UseConsole(console) - .Build(); - - // Act - var exitCode = await application.RunAsync(new[] {"--version"}); - - // Assert - exitCode.Should().Be(0); - stdOut.GetString().Trim().Should().Be("v6.9"); - - _output.WriteLine(stdOut.GetString()); + stdOut.Trim().Should().Be("cmd child"); } } } \ No newline at end of file diff --git a/CliFx.Tests/SpecsBase.cs b/CliFx.Tests/SpecsBase.cs new file mode 100644 index 0000000..035b6b9 --- /dev/null +++ b/CliFx.Tests/SpecsBase.cs @@ -0,0 +1,23 @@ +using System; +using CliFx.Infrastructure; +using CliFx.Tests.Utils.Extensions; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public abstract class SpecsBase : IDisposable + { + public ITestOutputHelper TestOutput { get; } + + public FakeInMemoryConsole FakeConsole { get; } = new(); + + protected SpecsBase(ITestOutputHelper testOutput) => + TestOutput = testOutput; + + public void Dispose() + { + FakeConsole.DumpToTestOutput(TestOutput); + FakeConsole.Dispose(); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/TypeActivationSpecs.cs b/CliFx.Tests/TypeActivationSpecs.cs new file mode 100644 index 0000000..40924b7 --- /dev/null +++ b/CliFx.Tests/TypeActivationSpecs.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Infrastructure; +using CliFx.Tests.Utils; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class TypeActivationSpecs : SpecsBase + { + public TypeActivationSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public async Task Default_type_activator_can_initialize_a_type_if_it_has_a_parameterless_constructor() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""foo""); + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .UseTypeActivator(new DefaultTypeActivator()) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("foo"); + } + + [Fact] + public async Task Default_type_activator_fails_if_the_type_does_not_have_a_parameterless_constructor() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public Command(string foo) {} + + public ValueTask ExecuteAsync(IConsole console) => default; +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .UseTypeActivator(new DefaultTypeActivator()) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Failed to create an instance of type"); + } + + [Fact] + public async Task Delegate_type_activator_can_initialize_a_type_using_a_custom_function() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + private readonly string _foo; + + public Command(string foo) => _foo = foo; + + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(_foo); + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .UseTypeActivator(type => Activator.CreateInstance(type, "hello world")!) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("hello world"); + } + + [Fact] + public async Task Delegate_type_activator_fails_if_the_underlying_function_returns_null() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // language=cs + @" +[Command] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + console.Output.WriteLine(""foo""); + return default; + } +}"); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .UseTypeActivator(_ => null!) + .Build(); + + // Act + var exitCode = await application.RunAsync( + Array.Empty(), + new Dictionary() + ); + + var stdErr = FakeConsole.ReadErrorString(); + + // Assert + exitCode.Should().NotBe(0); + stdErr.Should().Contain("Failed to create an instance of type"); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/UtilitiesSpecs.cs b/CliFx.Tests/UtilitiesSpecs.cs deleted file mode 100644 index d962dc9..0000000 --- a/CliFx.Tests/UtilitiesSpecs.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.IO; -using System.Linq; -using CliFx.Utilities; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace CliFx.Tests -{ - public class UtilitiesSpecs - { - private readonly ITestOutputHelper _output; - - public UtilitiesSpecs(ITestOutputHelper output) => _output = output; - - [Fact] - public void Progress_ticker_can_be_used_to_report_progress_to_console() - { - // Arrange - using var stdOut = new MemoryStream(); - var console = new VirtualConsole(output: stdOut, isOutputRedirected: false); - - var ticker = console.CreateProgressTicker(); - - var progressValues = Enumerable.Range(0, 100).Select(p => p / 100.0).ToArray(); - var progressStringValues = progressValues.Select(p => p.ToString("P2")).ToArray(); - - // Act - foreach (var progress in progressValues) - ticker.Report(progress); - - var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()); - - // Assert - stdOutData.Should().ContainAll(progressStringValues); - - _output.WriteLine(stdOutData); - } - - [Fact] - public void Progress_ticker_does_not_write_to_console_if_output_is_redirected() - { - // Arrange - using var stdOut = new MemoryStream(); - var console = new VirtualConsole(output: stdOut); - - var ticker = console.CreateProgressTicker(); - - var progressValues = Enumerable.Range(0, 100).Select(p => p / 100.0).ToArray(); - - // Act - foreach (var progress in progressValues) - ticker.Report(progress); - - var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()); - - // Assert - stdOutData.Should().BeEmpty(); - - _output.WriteLine(stdOutData); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Utils/DynamicCommandBuilder.cs b/CliFx.Tests/Utils/DynamicCommandBuilder.cs new file mode 100644 index 0000000..c7a78fb --- /dev/null +++ b/CliFx.Tests/Utils/DynamicCommandBuilder.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace CliFx.Tests.Utils +{ + // This class uses Roslyn to compile commands dynamically. + // + // It allows us to collocate commands with tests more + // easily, which helps a lot when reasoning about them. + // Unfortunately, this comes at a cost of static typing, + // but this is still a worthwhile trade off. + // + // Maybe one day C# will allow declaring classes inside + // methods and doing this will no longer be necessary. + // Language proposal: https://github.com/dotnet/csharplang/discussions/130 + internal static class DynamicCommandBuilder + { + public static IReadOnlyList CompileMany(string sourceCode) + { + // Get default system namespaces + var defaultSystemNamespaces = new[] + { + "System", + "System.Collections", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks", + "System.Globalization" + }; + + // Get default CliFx namespaces + var defaultCliFxNamespaces = typeof(ICommand) + .Assembly + .GetTypes() + .Where(t => t.IsPublic) + .Select(t => t.Namespace) + .Distinct() + .ToArray(); + + // Append default imports to the source code + var sourceCodeWithUsings = + string.Join(Environment.NewLine, defaultSystemNamespaces.Select(n => $"using {n};")) + + string.Join(Environment.NewLine, defaultCliFxNamespaces.Select(n => $"using {n};")) + + Environment.NewLine + + sourceCode; + + // Parse the source code + var ast = SyntaxFactory.ParseSyntaxTree( + SourceText.From(sourceCodeWithUsings), + CSharpParseOptions.Default + ); + + // Compile the code to IL + var compilation = CSharpCompilation.Create( + "CliFxTests_DynamicAssembly_" + Guid.NewGuid(), + new[] {ast}, + new[] + { + MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location), + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DynamicCommandBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ICommand).Assembly.Location) + }, + // DLL to avoid having to define the Main() method + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var compilationErrors = compilation + .GetDiagnostics() + .Where(d => d.Severity >= DiagnosticSeverity.Error) + .ToArray(); + + if (compilationErrors.Any()) + { + throw new InvalidOperationException( + "Failed to compile code." + + Environment.NewLine + + string.Join(Environment.NewLine, compilationErrors.Select(e => e.ToString())) + ); + } + + // Emit the code to an in-memory buffer + using var buffer = new MemoryStream(); + var emit = compilation.Emit(buffer); + + var emitErrors = emit + .Diagnostics + .Where(d => d.Severity >= DiagnosticSeverity.Error) + .ToArray(); + + if (emitErrors.Any()) + { + throw new InvalidOperationException( + "Failed to emit code." + + Environment.NewLine + + string.Join(Environment.NewLine, emitErrors.Select(e => e.ToString())) + ); + } + + // Load the generated assembly + var generatedAssembly = Assembly.Load(buffer.ToArray()); + + // Return all defined commands + var commandTypes = generatedAssembly + .GetTypes() + .Where(t => t.IsAssignableTo(typeof(ICommand))) + .ToArray(); + + if (commandTypes.Length <= 0) + { + throw new InvalidOperationException( + "There are no command definitions in the provide source code." + ); + } + + return commandTypes; + } + + public static Type Compile(string sourceCode) + { + var commandTypes = CompileMany(sourceCode); + + if (commandTypes.Count > 1) + { + throw new InvalidOperationException( + "There are more than one command definitions in the provide source code." + ); + } + + return commandTypes.Single(); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Utils/Extensions/AssertionExtensions.cs b/CliFx.Tests/Utils/Extensions/AssertionExtensions.cs new file mode 100644 index 0000000..ea88960 --- /dev/null +++ b/CliFx.Tests/Utils/Extensions/AssertionExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using FluentAssertions.Collections; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace CliFx.Tests.Utils.Extensions +{ + internal static class AssertionExtensions + { + public static AndConstraint ConsistOfLines( + this StringAssertions assertions, + IEnumerable lines) + { + var actualLines = assertions.Subject.Split(new[] {'\n', '\r'}, StringSplitOptions.RemoveEmptyEntries); + + return actualLines.Should().Equal(lines); + } + + public static AndConstraint ConsistOfLines( + this StringAssertions assertions, + params string[] lines) => + assertions.ConsistOfLines((IEnumerable) lines); + + public static AndConstraint ContainAllInOrder( + this StringAssertions assertions, + IEnumerable values) + { + var lastIndex = 0; + + foreach (var value in values) + { + var index = assertions.Subject.IndexOf(value, lastIndex, StringComparison.Ordinal); + + if (index < 0) + { + Execute.Assertion.FailWith( + $"Expected string '{assertions.Subject}' to contain '{value}' after position {lastIndex}." + ); + } + + lastIndex = index; + } + + return new(assertions); + } + + public static AndConstraint ContainAllInOrder( + this StringAssertions assertions, + params string[] values) => + assertions.ContainAllInOrder((IEnumerable) values); + } +} \ No newline at end of file diff --git a/CliFx.Tests/Utils/Extensions/ConsoleExtensions.cs b/CliFx.Tests/Utils/Extensions/ConsoleExtensions.cs new file mode 100644 index 0000000..793c3fa --- /dev/null +++ b/CliFx.Tests/Utils/Extensions/ConsoleExtensions.cs @@ -0,0 +1,17 @@ +using CliFx.Infrastructure; +using Xunit.Abstractions; + +namespace CliFx.Tests.Utils.Extensions +{ + internal static class ConsoleExtensions + { + public static void DumpToTestOutput(this FakeInMemoryConsole console, ITestOutputHelper testOutputHelper) + { + testOutputHelper.WriteLine("[*] Captured standard output:"); + testOutputHelper.WriteLine(console.ReadOutputString()); + + testOutputHelper.WriteLine("[*] Captured standard error:"); + testOutputHelper.WriteLine(console.ReadErrorString()); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Utils/NoOpCommand.cs b/CliFx.Tests/Utils/NoOpCommand.cs new file mode 100644 index 0000000..c0b77ca --- /dev/null +++ b/CliFx.Tests/Utils/NoOpCommand.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace CliFx.Tests.Utils +{ + [Command] + public class NoOpCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx/ApplicationMetadata.cs b/CliFx/ApplicationMetadata.cs index 0f778c1..23d31aa 100644 --- a/CliFx/ApplicationMetadata.cs +++ b/CliFx/ApplicationMetadata.cs @@ -18,7 +18,7 @@ /// /// Application version text. /// - public string VersionText { get; } + public string Version { get; } /// /// Application description. @@ -31,12 +31,12 @@ public ApplicationMetadata( string title, string executableName, - string versionText, + string version, string? description) { Title = title; ExecutableName = executableName; - VersionText = versionText; + Version = version; Description = description; } } diff --git a/CliFx/ArgumentValueConverter.cs b/CliFx/ArgumentValueConverter.cs deleted file mode 100644 index 7f47f47..0000000 --- a/CliFx/ArgumentValueConverter.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CliFx -{ - /// - /// Implements custom conversion logic that maps an argument value to a domain type. - /// - /// - /// This type is public for legacy reasons. - /// Please derive from instead. - /// - public interface IArgumentValueConverter - { - /// - /// Converts an input value to object of required type. - /// - public object ConvertFrom(string value); - } - - /// - /// A base type for custom argument converters. - /// - public abstract class ArgumentValueConverter : IArgumentValueConverter - { - /// - /// Converts an input value to object of required type. - /// - public abstract T ConvertFrom(string value); - - object IArgumentValueConverter.ConvertFrom(string value) => ConvertFrom(value)!; - } -} \ No newline at end of file diff --git a/CliFx/ArgumentValueValidator.cs b/CliFx/ArgumentValueValidator.cs deleted file mode 100644 index 9134b9c..0000000 --- a/CliFx/ArgumentValueValidator.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace CliFx -{ - /// - /// Represents a result of a validation. - /// - public partial class ValidationResult - { - /// - /// Whether validation was successful. - /// - public bool IsValid => ErrorMessage == null; - - /// - /// If validation has failed, contains the associated error, otherwise null. - /// - public string? ErrorMessage { get; } - - /// - /// Initializes an instance of . - /// - public ValidationResult(string? errorMessage = null) => - ErrorMessage = errorMessage; - } - - public partial class ValidationResult - { - /// - /// Creates successful result, meaning that the validation has passed. - /// - public static ValidationResult Ok() => new(); - - /// - /// Creates an error result, meaning that the validation has failed. - /// - public static ValidationResult Error(string message) => new(message); - } - - internal interface IArgumentValueValidator - { - ValidationResult Validate(object value); - } - - /// - /// A base type for custom argument validators. - /// - public abstract class ArgumentValueValidator : IArgumentValueValidator - { - /// - /// Validates the value. - /// - public abstract ValidationResult Validate(T value); - - ValidationResult IArgumentValueValidator.Validate(object value) => Validate((T) value); - } -} diff --git a/CliFx/Attributes/CommandArgumentAttribute.cs b/CliFx/Attributes/CommandArgumentAttribute.cs deleted file mode 100644 index b400b88..0000000 --- a/CliFx/Attributes/CommandArgumentAttribute.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace CliFx.Attributes -{ - /// - /// Properties shared by parameter and option arguments. - /// - [AttributeUsage(AttributeTargets.Property)] - public abstract class CommandArgumentAttribute : Attribute - { - /// - /// Option description, which is used in help text. - /// - public string? Description { get; set; } - - /// - /// Type of converter to use when mapping the argument value. - /// Converter must derive from . - /// - public Type? Converter { get; set; } - - /// - /// Types of validators to use when mapping the argument value. - /// Validators must derive from . - /// - public Type[] Validators { get; set; } = Array.Empty(); - } -} \ No newline at end of file diff --git a/CliFx/Attributes/CommandAttribute.cs b/CliFx/Attributes/CommandAttribute.cs index 3bd7f81..23f5d2f 100644 --- a/CliFx/Attributes/CommandAttribute.cs +++ b/CliFx/Attributes/CommandAttribute.cs @@ -9,15 +9,19 @@ namespace CliFx.Attributes public class CommandAttribute : Attribute { /// - /// Command name. - /// If the name is not set, the command is treated as a default command, i.e. the one that gets executed when the user - /// does not specify a command name in the arguments. - /// All commands in an application must have different names. Likewise, only one command without a name is allowed. + /// Command's name. /// + /// + /// Command can have no name, in which case it's treated as the default command. + /// + /// All commands registered in an application must have unique names (comparison IS NOT case-sensitive). + /// Only one command without a name is allowed in an application. + /// public string? Name { get; } /// - /// Command description, which is used in help text. + /// Command description. + /// This is shown to the user in the help text. /// public string? Description { get; set; } diff --git a/CliFx/Attributes/CommandOptionAttribute.cs b/CliFx/Attributes/CommandOptionAttribute.cs index efddfea..d2092fb 100644 --- a/CliFx/Attributes/CommandOptionAttribute.cs +++ b/CliFx/Attributes/CommandOptionAttribute.cs @@ -1,4 +1,5 @@ using System; +using CliFx.Extensibility; namespace CliFx.Attributes { @@ -6,31 +7,62 @@ namespace CliFx.Attributes /// Annotates a property that defines a command option. /// [AttributeUsage(AttributeTargets.Property)] - public class CommandOptionAttribute : CommandArgumentAttribute + public class CommandOptionAttribute : Attribute { /// - /// Option name (must be longer than a single character). - /// Either or must be set. - /// All options in a command must have different names (comparison is not case-sensitive). + /// Option name. /// + /// + /// Must contain at least two characters and start with a letter. + /// Either or must be set. + /// All options in a command must have unique names (comparison IS NOT case-sensitive). + /// public string? Name { get; } /// - /// Option short name (single character). - /// Either or must be set. - /// All options in a command must have different short names (comparison is case-sensitive). + /// Option short name. /// + /// + /// Either or must be set. + /// All options in a command must have unique short names (comparison IS case-sensitive). + /// public char? ShortName { get; } /// - /// Whether an option is required. + /// Whether this option is required. + /// If an option is required, the user will get an error if they don't set it. /// public bool IsRequired { get; set; } /// - /// Environment variable that will be used as fallback if no option value is specified. + /// Environment variable whose value will be used as a fallback if the option + /// has not been explicitly set through command line arguments. /// - public string? EnvironmentVariableName { get; set; } + public string? EnvironmentVariable { get; set; } + + /// + /// Option description. + /// This is shown to the user in the help text. + /// + public string? Description { get; set; } + + /// + /// Custom converter used for mapping the raw command line argument into + /// a value expected by the underlying property. + /// + /// + /// Converter must derive from . + /// + public Type? Converter { get; set; } + + /// + /// Custom validators used for verifying the value of the underlying + /// property, after it has been bound. + /// + /// + /// Validators must derive from . + /// + public Type[] Validators { get; set; } = Array.Empty(); /// /// Initializes an instance of . diff --git a/CliFx/Attributes/CommandParameterAttribute.cs b/CliFx/Attributes/CommandParameterAttribute.cs index 85624e4..b226212 100644 --- a/CliFx/Attributes/CommandParameterAttribute.cs +++ b/CliFx/Attributes/CommandParameterAttribute.cs @@ -1,4 +1,5 @@ using System; +using CliFx.Extensibility; namespace CliFx.Attributes { @@ -6,21 +7,55 @@ namespace CliFx.Attributes /// Annotates a property that defines a command parameter. /// [AttributeUsage(AttributeTargets.Property)] - public class CommandParameterAttribute : CommandArgumentAttribute + public class CommandParameterAttribute : Attribute { /// - /// Order of this parameter compared to other parameters. - /// All parameters in a command must have different order. - /// Parameter whose type is a non-scalar (e.g. array), must be the last in order and only one such parameter is allowed. + /// Parameter order. /// + /// + /// Higher order means the parameter appears later, lower order means + /// it appears earlier. + /// + /// All parameters in a command must have unique order. + /// + /// Parameter whose type is a non-scalar (e.g. array), must always be the last in order. + /// Only one non-scalar parameter is allowed in a command. + /// public int Order { get; } /// - /// Parameter name, which is only used in help text. - /// If this isn't specified, property name is used instead. + /// Parameter name. + /// This is shown to the user in the help text. /// + /// + /// If this isn't specified, parameter name is inferred from the property name. + /// public string? Name { get; set; } + /// + /// Parameter description. + /// This is shown to the user in the help text. + /// + public string? Description { get; set; } + + /// + /// Custom converter used for mapping the raw command line argument into + /// a value expected by the underlying property. + /// + /// + /// Converter must derive from . + /// + public Type? Converter { get; set; } + + /// + /// Custom validators used for verifying the value of the underlying + /// property, after it has been bound. + /// + /// + /// Validators must derive from . + /// + public Type[] Validators { get; set; } = Array.Empty(); + /// /// Initializes an instance of . /// diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index dd14211..3451a03 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -1,51 +1,85 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; -using CliFx.Attributes; -using CliFx.Domain; using CliFx.Exceptions; -using CliFx.Internal; +using CliFx.Formatting; +using CliFx.Infrastructure; +using CliFx.Input; +using CliFx.Schema; +using CliFx.Utils; +using CliFx.Utils.Extensions; namespace CliFx { /// /// Command line application facade. /// - public partial class CliApplication + public class CliApplication { - private readonly ApplicationMetadata _metadata; - private readonly ApplicationConfiguration _configuration; + /// + /// Application metadata. + /// + public ApplicationMetadata Metadata { get; } + + /// + /// Application configuration. + /// + public ApplicationConfiguration Configuration { get; } + private readonly IConsole _console; private readonly ITypeActivator _typeActivator; - private readonly HelpTextWriter _helpTextWriter; + private readonly CommandBinder _commandBinder; /// /// Initializes an instance of . /// public CliApplication( - ApplicationMetadata metadata, ApplicationConfiguration configuration, - IConsole console, ITypeActivator typeActivator) + ApplicationMetadata metadata, + ApplicationConfiguration configuration, + IConsole console, + ITypeActivator typeActivator) { - _metadata = metadata; - _configuration = configuration; + Metadata = metadata; + Configuration = configuration; _console = console; _typeActivator = typeActivator; - _helpTextWriter = new HelpTextWriter(metadata, console); + _commandBinder = new CommandBinder(typeActivator); } - private async ValueTask LaunchAndWaitForDebuggerAsync() + private bool IsDebugModeEnabled(CommandInput commandInput) => + Configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified; + + private bool IsPreviewModeEnabled(CommandInput commandInput) => + Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; + + private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => + commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified || + // Show help text also in case the fallback default command is + // executed without any arguments. + commandSchema == FallbackDefaultCommand.Schema && + string.IsNullOrWhiteSpace(commandInput.CommandName) && + !commandInput.Parameters.Any() && + !commandInput.Options.Any(); + + private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) => + commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified; + + private async ValueTask PromptDebuggerAsync() { - var processId = ProcessEx.GetCurrentProcessId(); + using (_console.WithForegroundColor(ConsoleColor.Green)) + { + var processId = ProcessEx.GetCurrentProcessId(); - _console.WithForegroundColor(ConsoleColor.Green, () => - _console.Output.WriteLine($"Attach debugger to PID {processId} to continue.")); + _console.Output.WriteLine( + $"Attach debugger to PID {processId} to continue." + ); + } + // Try to also launch debugger ourselves (only works if VS is installed) Debugger.Launch(); while (!Debugger.IsAttached) @@ -54,66 +88,87 @@ namespace CliFx } } - private void WriteCommandLineInput(CommandInput input) + private async ValueTask RunAsync(ApplicationSchema applicationSchema, CommandInput commandInput) { - // Command name - if (!string.IsNullOrWhiteSpace(input.CommandName)) + // Handle debug directive + if (IsDebugModeEnabled(commandInput)) { - _console.WithForegroundColor(ConsoleColor.Cyan, () => - _console.Output.Write(input.CommandName)); - - _console.Output.Write(' '); + await PromptDebuggerAsync(); } - // Parameters - foreach (var parameter in input.Parameters) + // Handle preview directive + if (IsPreviewModeEnabled(commandInput)) { - _console.Output.Write('<'); - - _console.WithForegroundColor(ConsoleColor.White, () => - _console.Output.Write(parameter)); - - _console.Output.Write('>'); - _console.Output.Write(' '); + _console.WriteCommandInput(commandInput); + return 0; } - // Options - foreach (var option in input.Options) - { - _console.Output.Write('['); + // Try to get the command schema that matches the input + var commandSchema = + applicationSchema.TryFindCommand(commandInput.CommandName) ?? + applicationSchema.TryFindDefaultCommand() ?? + FallbackDefaultCommand.Schema; - _console.WithForegroundColor(ConsoleColor.White, () => + // Activate command instance + var commandInstance = commandSchema == FallbackDefaultCommand.Schema + ? new FallbackDefaultCommand() // bypass activator + : (ICommand) _typeActivator.CreateInstance(commandSchema.Type); + + // Assemble help context + var helpContext = new HelpContext( + Metadata, + applicationSchema, + commandSchema, + commandSchema.GetValues(commandInstance) + ); + + // Handle help option + if (ShouldShowHelpText(commandSchema, commandInput)) + { + _console.WriteHelpText(helpContext); + return 0; + } + + // Handle version option + if (ShouldShowVersionText(commandSchema, commandInput)) + { + _console.Output.WriteLine(Metadata.Version); + return 0; + } + + // Starting from this point, we may produce exceptions that are meant for the + // end user of the application (i.e. invalid input, command exception, etc). + // Catch these exceptions here, print them to the console, and don't let them + // propagate further. + try + { + // Bind and execute command + _commandBinder.Bind(commandInput, commandSchema, commandInstance); + await commandInstance.ExecuteAsync(_console); + + return 0; + } + catch (CliFxException ex) + { + _console.WriteException(ex); + + if (ex.ShowHelp) { - // Alias - _console.Output.Write(option.GetRawAlias()); + _console.Output.WriteLine(); + _console.WriteHelpText(helpContext); + } - // Values - if (option.Values.Any()) - { - _console.Output.Write(' '); - _console.Output.Write(option.GetRawValues()); - } - }); - - _console.Output.Write(']'); - _console.Output.Write(' '); + return ex.ExitCode; } - - _console.Output.WriteLine(); } - private ICommand GetCommandInstance(CommandSchema command) => - command != FallbackDefaultCommand.Schema - ? (ICommand) _typeActivator.CreateInstance(command.Type) - : new FallbackDefaultCommand(); - /// - /// Runs the application with specified command line arguments and environment variables, and returns the exit code. + /// Runs the application with the specified command line arguments and environment variables. + /// Returns an exit code which indicates whether the application completed successfully. /// /// - /// If a is thrown during command execution, it will be handled and routed to the console. - /// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within - /// this method will be handled and routed to the console as well. + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. /// public async ValueTask RunAsync( IReadOnlyList commandLineArguments, @@ -121,164 +176,66 @@ namespace CliFx { try { - var root = RootSchema.Resolve(_configuration.CommandTypes); - var input = CommandInput.Parse(commandLineArguments, root.GetCommandNames()); + // Console colors may have already been overriden by the parent process, + // so we need to reset it to make sure that everything we write looks properly. + _console.ResetColor(); - // Debug mode - if (_configuration.IsDebugModeAllowed && input.IsDebugDirectiveSpecified) - { - await LaunchAndWaitForDebuggerAsync(); - } + var applicationSchema = ApplicationSchema.Resolve(Configuration.CommandTypes); - // Preview mode - if (_configuration.IsPreviewModeAllowed && input.IsPreviewDirectiveSpecified) - { - WriteCommandLineInput(input); - return ExitCode.Success; - } - - // Try to get the command matching the input or fallback to default - var command = - root.TryFindCommand(input.CommandName) ?? - root.TryFindDefaultCommand() ?? - FallbackDefaultCommand.Schema; - - // Version option - if (command.IsVersionOptionAvailable && input.IsVersionOptionSpecified) - { - _console.Output.WriteLine(_metadata.VersionText); - return ExitCode.Success; - } - - // Get command instance (also used in help text) - var instance = GetCommandInstance(command); - - // To avoid instantiating the command twice, we need to get default values - // before the arguments are bound to the properties - var defaultValues = command.GetArgumentValues(instance); - - // Help option - if (command.IsHelpOptionAvailable && input.IsHelpOptionSpecified || - command == FallbackDefaultCommand.Schema && !input.Parameters.Any() && !input.Options.Any()) - { - _helpTextWriter.Write(root, command, defaultValues); - return ExitCode.Success; - } - - // Bind arguments - try - { - command.Bind(instance, input, environmentVariables); - } - // This may throw exceptions which are useful only to the end-user - catch (CliFxException ex) - { - _console.WithForegroundColor(ConsoleColor.Red, () => - _console.Error.WriteLine(ex.ToString()) - ); - - _helpTextWriter.Write(root, command, defaultValues); - - return ExitCode.FromException(ex); - } - - // Execute the command - try - { - await instance.ExecuteAsync(_console); - return ExitCode.Success; - } - // Swallow command exceptions and route them to the console - catch (CommandException ex) - { - _console.WithForegroundColor(ConsoleColor.Red, () => - _console.Error.WriteLine(ex.ToString()) - ); - - if (ex.ShowHelp) - { - _helpTextWriter.Write(root, command, defaultValues); - } - - return ex.ExitCode; - } - } - // To prevent the app from showing the annoying Windows troubleshooting dialog, - // we handle all exceptions and route them to the console nicely. - // However, we don't want to swallow unhandled exceptions when the debugger is attached, - // because we still want the IDE to show them to the developer. - catch (Exception ex) when (!Debugger.IsAttached) - { - _console.WithColors(ConsoleColor.White, ConsoleColor.DarkRed, () => - _console.Error.Write("ERROR:") + var commandInput = CommandInput.Parse( + commandLineArguments, + environmentVariables, + applicationSchema.GetCommandNames() ); - _console.Error.Write(" "); + return await RunAsync(applicationSchema, commandInput); + } + // To prevent the app from showing the annoying troubleshooting dialog on Windows, + // we handle all exceptions ourselves and print them to the console. + // + // We only want to do that if the app is running in production, which we infer + // based on whether a debugger is attached to the process. + // + // When not running in production, we want the IDE to show exceptions to the + // developer, so we don't swallow them in that case. + catch (Exception ex) when (!Debugger.IsAttached) + { _console.WriteException(ex); - - return ExitCode.FromException(ex); + return 1; } } /// - /// Runs the application with specified command line arguments and returns the exit code. - /// Environment variables are retrieved automatically. + /// Runs the application with the specified command line arguments. + /// Environment variables are resolved automatically. + /// Returns an exit code which indicates whether the application completed successfully. /// /// - /// If a is thrown during command execution, it will be handled and routed to the console. - /// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within - /// this method will be handled and routed to the console as well. + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. /// - public async ValueTask RunAsync(IReadOnlyList commandLineArguments) - { - // Environment variable names are case-insensitive on Windows but are case-sensitive on Linux and macOS - var environmentVariables = Environment.GetEnvironmentVariables() - .Cast() - .ToDictionary(e => (string) e.Key, e => (string) e.Value, StringComparer.Ordinal); - - return await RunAsync(commandLineArguments, environmentVariables); - } + public async ValueTask RunAsync(IReadOnlyList commandLineArguments) => await RunAsync( + commandLineArguments, + // Use case-sensitive comparison because environment variables are + // case-sensitive on Linux and macOS (but not on Windows). + Environment + .GetEnvironmentVariables() + .ToDictionary(StringComparer.Ordinal) + ); /// - /// Runs the application and returns the exit code. - /// Command line arguments and environment variables are retrieved automatically. + /// Runs the application. + /// Command line arguments and environment variables are resolved automatically. + /// Returns an exit code which indicates whether the application completed successfully. /// /// - /// If a is thrown during command execution, it will be handled and routed to the console. - /// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within - /// this method will be handled and routed to the console as well. + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. /// - public async ValueTask RunAsync() - { - var commandLineArguments = Environment.GetCommandLineArgs() - .Skip(1) - .ToArray(); - - return await RunAsync(commandLineArguments); - } - } - - public partial class CliApplication - { - private static class ExitCode - { - public const int Success = 0; - - public static int FromException(Exception ex) => - ex is CommandException cmdEx - ? cmdEx.ExitCode - : 1; - } - - // Fallback default command used when none is defined in the application - [Command] - private class FallbackDefaultCommand : ICommand - { - public static CommandSchema Schema { get; } = CommandSchema.TryResolve(typeof(FallbackDefaultCommand))!; - - // Never actually executed - [ExcludeFromCodeCoverage] - public ValueTask ExecuteAsync(IConsole console) => default; - } + public async ValueTask RunAsync() => await RunAsync( + Environment.GetCommandLineArgs() + .Skip(1) // first element is the file path + .ToArray() + ); } } diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 92fd1a8..96badc2 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -3,13 +3,15 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using CliFx.Domain; -using CliFx.Internal.Extensions; +using CliFx.Attributes; +using CliFx.Infrastructure; +using CliFx.Schema; +using CliFx.Utils.Extensions; namespace CliFx { /// - /// Builds an instance of . + /// Builder for . /// public partial class CliApplicationBuilder { @@ -25,7 +27,7 @@ namespace CliFx private ITypeActivator? _typeActivator; /// - /// Adds a command of specified type to the application. + /// Adds a command to the application. /// public CliApplicationBuilder AddCommand(Type commandType) { @@ -35,7 +37,7 @@ namespace CliFx } /// - /// Adds a command of specified type to the application. + /// Adds a command the application. /// public CliApplicationBuilder AddCommand() where TCommand : ICommand => AddCommand(typeof(TCommand)); @@ -53,8 +55,11 @@ namespace CliFx /// /// Adds commands from the specified assembly to the application. - /// Only adds public valid command types. /// + /// + /// This method looks for public non-abstract classes that implement + /// and are annotated by . + /// public CliApplicationBuilder AddCommandsFrom(Assembly commandAssembly) { foreach (var commandType in commandAssembly.ExportedTypes.Where(CommandSchema.IsCommandType)) @@ -65,8 +70,11 @@ namespace CliFx /// /// Adds commands from the specified assemblies to the application. - /// Only adds public valid command types. /// + /// + /// This method looks for public non-abstract classes that implement + /// and are annotated by . + /// public CliApplicationBuilder AddCommandsFrom(IEnumerable commandAssemblies) { foreach (var commandAssembly in commandAssemblies) @@ -77,12 +85,15 @@ namespace CliFx /// /// Adds commands from the calling assembly to the application. - /// Only adds public valid command types. /// + /// + /// This method looks for public non-abstract classes that implement + /// and are annotated by . + /// public CliApplicationBuilder AddCommandsFromThisAssembly() => AddCommandsFrom(Assembly.GetCallingAssembly()); /// - /// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application. + /// Specifies whether debug mode (enabled with the [debug] directive) is allowed in the application. /// public CliApplicationBuilder AllowDebugMode(bool isAllowed = true) { @@ -91,7 +102,7 @@ namespace CliFx } /// - /// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application. + /// Specifies whether preview mode (enabled with the [preview] directive) is allowed in the application. /// public CliApplicationBuilder AllowPreviewMode(bool isAllowed = true) { @@ -100,36 +111,47 @@ namespace CliFx } /// - /// Sets application title, which appears in the help text. + /// Sets application title, which is shown in the help text. /// - public CliApplicationBuilder UseTitle(string title) + /// + /// By default, application title is inferred from the assembly name. + /// + public CliApplicationBuilder SetTitle(string title) { _title = title; return this; } /// - /// Sets application executable name, which appears in the help text. + /// Sets application executable name, which is shown in the help text. /// - public CliApplicationBuilder UseExecutableName(string executableName) + /// + /// By default, application executable name is inferred from the assembly file name. + /// The file name is also prefixed with `dotnet` if it's a DLL file. + /// + public CliApplicationBuilder SetExecutableName(string executableName) { _executableName = executableName; return this; } /// - /// Sets application version text, which appears in the help text and when the user requests version information. + /// Sets application version, which is shown in the help text or + /// when the user specifies the version option. /// - public CliApplicationBuilder UseVersionText(string versionText) + /// + /// By default, application version is inferred from the assembly version. + /// + public CliApplicationBuilder SetVersion(string version) { - _versionText = versionText; + _versionText = version; return this; } /// - /// Sets application description, which appears in the help text. + /// Sets application description, which is shown in the help text. /// - public CliApplicationBuilder UseDescription(string? description) + public CliApplicationBuilder SetDescription(string? description) { _description = description; return this; @@ -160,38 +182,55 @@ namespace CliFx UseTypeActivator(new DelegateTypeActivator(typeActivator)); /// - /// Creates an instance of using configured parameters. - /// Default values are used in place of parameters that were not specified. + /// Creates a configured instance of . /// public CliApplication Build() { - _title ??= GetDefaultTitle(); - _executableName ??= GetDefaultExecutableName(); - _versionText ??= GetDefaultVersionText(); - _console ??= new SystemConsole(); - _typeActivator ??= new DefaultTypeActivator(); + var metadata = new ApplicationMetadata( + _title ?? GetDefaultTitle(), + _executableName ?? GetDefaultExecutableName(), + _versionText ?? GetDefaultVersionText(), + _description + ); - var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description); - var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed); + var configuration = new ApplicationConfiguration( + _commandTypes.ToArray(), + _isDebugModeAllowed, + _isPreviewModeAllowed + ); - return new CliApplication(metadata, configuration, _console, _typeActivator); + return new CliApplication( + metadata, + configuration, + _console ?? new SystemConsole(), + _typeActivator ?? new DefaultTypeActivator() + ); } } public partial class CliApplicationBuilder { - private static readonly Lazy LazyEntryAssembly = new(Assembly.GetEntryAssembly); + private static readonly Lazy EntryAssemblyLazy = new(Assembly.GetEntryAssembly); - // Entry assembly is null in tests - private static Assembly? EntryAssembly => LazyEntryAssembly.Value; + // Entry assembly can be null, for example in tests + private static Assembly? EntryAssembly => EntryAssemblyLazy.Value; - private static string GetDefaultTitle() => EntryAssembly?.GetName().Name?? "App"; + private static string GetDefaultTitle() + { + var entryAssemblyName = EntryAssembly?.GetName().Name; + if (string.IsNullOrWhiteSpace(entryAssemblyName)) + return "App"; + + return entryAssemblyName; + } private static string GetDefaultExecutableName() { var entryAssemblyLocation = EntryAssembly?.Location; + if (string.IsNullOrWhiteSpace(entryAssemblyLocation)) + return "app"; - // The assembly can be an executable or a dll, depending on how it was packaged + // The assembly can be an .exe or a .dll, depending on how it was packaged var isDll = string.Equals( Path.GetExtension(entryAssemblyLocation), ".dll", @@ -202,12 +241,12 @@ namespace CliFx ? "dotnet " + Path.GetFileName(entryAssemblyLocation) : Path.GetFileNameWithoutExtension(entryAssemblyLocation); - return name ?? "app"; + return name; } private static string GetDefaultVersionText() => - EntryAssembly != null - ? $"v{EntryAssembly.GetName().Version.ToSemanticString()}" + EntryAssembly is not null + ? "v" + EntryAssembly.GetName().Version.ToSemanticString() : "v1.0"; } } \ No newline at end of file diff --git a/CliFx/CliFx.csproj b/CliFx/CliFx.csproj index 2db73c5..5ee37bb 100644 --- a/CliFx/CliFx.csproj +++ b/CliFx/CliFx.csproj @@ -3,8 +3,8 @@ netstandard2.1;netstandard2.0 $(Company) - Declarative framework for CLI applications - command line executable interface framework parser arguments net core + Declarative framework for building command line applications + command line executable interface framework parser arguments cli app application net core https://github.com/Tyrrrz/CliFx https://github.com/Tyrrrz/CliFx/blob/master/Changelog.md favicon.png @@ -13,7 +13,6 @@ true true embedded - $(TargetsForTfmSpecificContentInPackage);CopyAnalyzerToPackage @@ -29,11 +28,16 @@ - + + + + $(TargetsForTfmSpecificContentInPackage);CopyAnalyzerToPackage + + diff --git a/CliFx/CommandBinder.cs b/CliFx/CommandBinder.cs new file mode 100644 index 0000000..e527868 --- /dev/null +++ b/CliFx/CommandBinder.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using CliFx.Exceptions; +using CliFx.Extensibility; +using CliFx.Infrastructure; +using CliFx.Input; +using CliFx.Schema; +using CliFx.Utils.Extensions; + +namespace CliFx +{ + internal class CommandBinder + { + private readonly ITypeActivator _typeActivator; + private readonly IFormatProvider _formatProvider = CultureInfo.InvariantCulture; + + public CommandBinder(ITypeActivator typeActivator) + { + _typeActivator = typeActivator; + } + + private object? ConvertSingle(IMemberSchema memberSchema, string? rawValue, Type targetType) + { + // Custom converter + if (memberSchema.ConverterType is not null) + { + var converter = (IBindingConverter) _typeActivator.CreateInstance(memberSchema.ConverterType); + return converter.Convert(rawValue); + } + + // Assignable from string (e.g. string itself, object, etc) + if (targetType.IsAssignableFrom(typeof(string))) + { + return rawValue; + } + + // Special case for bool + if (targetType == typeof(bool)) + { + return string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue); + } + + // IConvertible primitives (int, double, char, etc) + if (targetType.IsConvertible()) + { + return Convert.ChangeType(rawValue, targetType, _formatProvider); + } + + // Special case for DateTimeOffset + if (targetType == typeof(DateTimeOffset)) + { + return DateTimeOffset.Parse(rawValue, _formatProvider); + } + + // Special case for TimeSpan + if (targetType == typeof(TimeSpan)) + { + return TimeSpan.Parse(rawValue, _formatProvider); + } + + // Enum + if (targetType.IsEnum) + { + // Null reference exception will be handled upstream + return Enum.Parse(targetType, rawValue!, true); + } + + // Nullable + var nullableUnderlyingType = targetType.TryGetNullableUnderlyingType(); + if (nullableUnderlyingType is not null) + { + return !string.IsNullOrWhiteSpace(rawValue) + ? ConvertSingle(memberSchema, rawValue, nullableUnderlyingType) + : null; + } + + // String-constructible (FileInfo, etc) + var stringConstructor = targetType.GetConstructor(new[] {typeof(string)}); + if (stringConstructor is not null) + { + return stringConstructor.Invoke(new object?[] {rawValue}); + } + + // String-parseable (with IFormatProvider) + var parseMethodWithFormatProvider = targetType.TryGetStaticParseMethod(true); + if (parseMethodWithFormatProvider is not null) + { + return parseMethodWithFormatProvider.Invoke(null, new object?[] {rawValue, _formatProvider}); + } + + // String-parseable (without IFormatProvider) + var parseMethod = targetType.TryGetStaticParseMethod(); + if (parseMethod is not null) + { + return parseMethod.Invoke(null, new object?[] {rawValue}); + } + + throw CliFxException.InternalError( + $"{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type." + + Environment.NewLine + + $"There is no known way to convert a string value into an instance of type `{targetType.FullName}`." + + Environment.NewLine + + "To fix this, either change the property to use a supported type or configure a custom converter." + ); + } + + private object? ConvertMultiple( + IMemberSchema memberSchema, + IReadOnlyList rawValues, + Type targetEnumerableType, + Type targetElementType) + { + var array = rawValues + .Select(v => ConvertSingle(memberSchema, v, targetElementType)) + .ToNonGenericArray(targetElementType); + + var arrayType = array.GetType(); + + // Assignable from an array (T[], IReadOnlyList, etc) + if (targetEnumerableType.IsAssignableFrom(arrayType)) + { + return array; + } + + // Array-constructible (List, HashSet, etc) + var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType}); + if (arrayConstructor is not null) + { + return arrayConstructor.Invoke(new object?[] {array}); + } + + throw CliFxException.InternalError( + $"{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type." + + Environment.NewLine + + $"There is no known way to convert an array of `{targetElementType.FullName}` into an instance of type `{targetEnumerableType.FullName}`." + + Environment.NewLine + + "To fix this, change the property to use a type which can be assigned from an array or a type that has a constructor which accepts an array." + ); + } + + private object? ConvertMember(IMemberSchema memberSchema, IReadOnlyList rawValues) + { + var targetType = memberSchema.Property.Type; + + try + { + // Non-scalar + var enumerableUnderlyingType = targetType.TryGetEnumerableUnderlyingType(); + if (targetType != typeof(string) && enumerableUnderlyingType is not null) + { + return ConvertMultiple(memberSchema, rawValues, targetType, enumerableUnderlyingType); + } + + // Scalar + if (rawValues.Count <= 1) + { + return ConvertSingle(memberSchema, rawValues.SingleOrDefault(), targetType); + } + } + catch (Exception ex) when (ex is not CliFxException) // don't wrap CliFxException + { + throw CliFxException.UserError( + $"{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} cannot be set from provided argument(s):" + + Environment.NewLine + + rawValues.Select(v => '<' + v + '>').JoinToString(" ") + + Environment.NewLine + + $"Error: {ex.Message}", + ex + ); + } + + // Mismatch (scalar but too many values) + throw CliFxException.UserError( + $"{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple:" + + Environment.NewLine + + rawValues.Select(v => '<' + v + '>').JoinToString(" ") + ); + } + + private void ValidateMember(IMemberSchema memberSchema, object? convertedValue) + { + var errors = new List(); + + foreach (var validatorType in memberSchema.ValidatorTypes) + { + var validator = (IBindingValidator) _typeActivator.CreateInstance(validatorType); + var error = validator.Validate(convertedValue); + + if (error is not null) + errors.Add(error); + } + + if (errors.Any()) + { + throw CliFxException.UserError( + $"{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has been provided with an invalid value." + + Environment.NewLine + + "Error(s):" + + Environment.NewLine + + errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine) + ); + } + } + + private void BindMember(IMemberSchema memberSchema, ICommand commandInstance, IReadOnlyList rawValues) + { + var convertedValue = ConvertMember(memberSchema, rawValues); + ValidateMember(memberSchema, convertedValue); + + memberSchema.Property.SetValue(commandInstance, convertedValue); + } + + private void BindParameters(CommandInput commandInput, CommandSchema commandSchema, ICommand commandInstance) + { + // Ensure there are no unexpected parameters and that all parameters are provided + var remainingParameterInputs = commandInput.Parameters.ToList(); + var remainingParameterSchemas = commandSchema.Parameters.ToList(); + + var position = 0; + + foreach (var parameterSchema in commandSchema.Parameters.OrderBy(p => p.Order)) + { + // Break when there are no remaining inputs + if (position >= commandInput.Parameters.Count) + break; + + // Scalar - take one input at the current position + if (parameterSchema.Property.IsScalar()) + { + var parameterInput = commandInput.Parameters[position]; + + var rawValues = new[] {parameterInput.Value}; + BindMember(parameterSchema, commandInstance, rawValues); + + position++; + + remainingParameterInputs.Remove(parameterInput); + } + // Non-scalar - take all remaining inputs starting from the current position + else + { + var parameterInputs = commandInput.Parameters.Skip(position).ToArray(); + + var rawValues = parameterInputs.Select(p => p.Value).ToArray(); + BindMember(parameterSchema, commandInstance, rawValues); + + position += parameterInputs.Length; + + remainingParameterInputs.RemoveRange(parameterInputs); + } + + remainingParameterSchemas.Remove(parameterSchema); + } + + if (remainingParameterInputs.Any()) + { + throw CliFxException.UserError( + "Unexpected parameter(s):" + + Environment.NewLine + + remainingParameterInputs + .Select(p => p.GetFormattedIdentifier()) + .JoinToString(" ") + ); + } + + if (remainingParameterSchemas.Any()) + { + throw CliFxException.UserError( + "Missing parameter(s):" + + Environment.NewLine + + remainingParameterSchemas + .Select(o => o.GetFormattedIdentifier()) + .JoinToString(" ") + ); + } + } + + private void BindOptions(CommandInput commandInput, CommandSchema commandSchema, ICommand commandInstance) + { + // Ensure there are no unrecognized options and that all required options are set + var remainingOptionInputs = commandInput.Options.ToList(); + var remainingRequiredOptionSchemas = commandSchema.Options.Where(o => o.IsRequired).ToList(); + + foreach (var optionSchema in commandSchema.Options) + { + var optionInputs = commandInput + .Options + .Where(o => optionSchema.MatchesIdentifier(o.Identifier)) + .ToArray(); + + var environmentVariableInput = commandInput + .EnvironmentVariables + .FirstOrDefault(e => optionSchema.MatchesEnvironmentVariable(e.Name)); + + // Direct input + if (optionInputs.Any()) + { + var rawValues = optionInputs.SelectMany(o => o.Values).ToArray(); + + BindMember(optionSchema, commandInstance, rawValues); + + // Required options require at least one value to be set + if (rawValues.Any()) + remainingRequiredOptionSchemas.Remove(optionSchema); + } + // Environment variable + else if (environmentVariableInput is not null) + { + var rawValues = optionSchema.Property.IsScalar() + ? new[] {environmentVariableInput.Value} + : environmentVariableInput.SplitValues(); + + BindMember(optionSchema, commandInstance, rawValues); + + // Required options require at least one value to be set + if (rawValues.Any()) + remainingRequiredOptionSchemas.Remove(optionSchema); + } + // No input - skip + else + { + continue; + } + + remainingOptionInputs.RemoveRange(optionInputs); + } + + if (remainingOptionInputs.Any()) + { + throw CliFxException.UserError( + "Unrecognized option(s):" + + Environment.NewLine + + remainingOptionInputs + .Select(o => o.GetFormattedIdentifier()) + .JoinToString(", ") + ); + } + + if (remainingRequiredOptionSchemas.Any()) + { + throw CliFxException.UserError( + "Missing required option(s):" + + Environment.NewLine + + remainingRequiredOptionSchemas + .Select(o => o.GetFormattedIdentifier()) + .JoinToString(", ") + ); + } + } + + public void Bind(CommandInput commandInput, CommandSchema commandSchema, ICommand commandInstance) + { + BindParameters(commandInput, commandSchema, commandInstance); + BindOptions(commandInput, commandSchema, commandInstance); + } + } +} \ No newline at end of file diff --git a/CliFx/DefaultTypeActivator.cs b/CliFx/DefaultTypeActivator.cs deleted file mode 100644 index 5a6d27e..0000000 --- a/CliFx/DefaultTypeActivator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using CliFx.Exceptions; -using CliFx.Internal.Extensions; - -namespace CliFx -{ - /// - /// Type activator that uses the class to instantiate objects. - /// - public class DefaultTypeActivator : ITypeActivator - { - /// - public object CreateInstance(Type type) - { - try - { - return type.CreateInstance(); - } - catch (Exception ex) - { - throw CliFxException.DefaultActivatorFailed(type, ex); - } - } - } -} \ No newline at end of file diff --git a/CliFx/DelegateTypeActivator.cs b/CliFx/DelegateTypeActivator.cs deleted file mode 100644 index 5439e93..0000000 --- a/CliFx/DelegateTypeActivator.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using CliFx.Exceptions; - -namespace CliFx -{ - /// - /// Type activator that uses the specified delegate to instantiate objects. - /// - public class DelegateTypeActivator : ITypeActivator - { - private readonly Func _func; - - /// - /// Initializes an instance of . - /// - public DelegateTypeActivator(Func func) => _func = func; - - /// - public object CreateInstance(Type type) => - _func(type) ?? throw CliFxException.DelegateActivatorReturnedNull(type); - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandArgumentSchema.cs b/CliFx/Domain/CommandArgumentSchema.cs deleted file mode 100644 index ae6f37c..0000000 --- a/CliFx/Domain/CommandArgumentSchema.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using CliFx.Exceptions; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal abstract partial class CommandArgumentSchema - { - // Property can be null on built-in arguments (help and version options) - public PropertyInfo? Property { get; } - - public string? Description { get; } - - public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null; - - public Type? ConverterType { get; } - - public Type[] ValidatorTypes { get; } - - protected CommandArgumentSchema( - PropertyInfo? property, - string? description, - Type? converterType, - Type[] validatorTypes) - { - Property = property; - Description = description; - ConverterType = converterType; - ValidatorTypes = validatorTypes; - } - - private Type? TryGetEnumerableArgumentUnderlyingType() => - Property != null && Property.PropertyType != typeof(string) - ? Property.PropertyType.TryGetEnumerableUnderlyingType() - : null; - - private object? ConvertScalar(string? value, Type targetType) - { - try - { - // Custom conversion (always takes highest priority) - if (ConverterType != null) - return ConverterType.CreateInstance().ConvertFrom(value!); - - // No conversion necessary - if (targetType == typeof(object) || targetType == typeof(string)) - return value; - - // Bool conversion (special case) - if (targetType == typeof(bool)) - return string.IsNullOrWhiteSpace(value) || bool.Parse(value); - - // Primitive conversion - var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType); - if (primitiveConverter != null && !string.IsNullOrWhiteSpace(value)) - return primitiveConverter(value); - - // Enum conversion - if (targetType.IsEnum && !string.IsNullOrWhiteSpace(value)) - return Enum.Parse(targetType, value, true); - - // Nullable conversion - var nullableUnderlyingType = targetType.TryGetNullableUnderlyingType(); - if (nullableUnderlyingType != null) - return !string.IsNullOrWhiteSpace(value) - ? ConvertScalar(value, nullableUnderlyingType) - : null; - - // String-constructible conversion - var stringConstructor = targetType.GetConstructor(new[] {typeof(string)}); - if (stringConstructor != null) - return stringConstructor.Invoke(new object[] {value!}); - - // String-parseable conversion (with format provider) - var parseMethodWithFormatProvider = targetType.TryGetStaticParseMethod(true); - if (parseMethodWithFormatProvider != null) - return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, FormatProvider}); - - // String-parseable conversion (without format provider) - var parseMethod = targetType.TryGetStaticParseMethod(); - if (parseMethod != null) - return parseMethod.Invoke(null, new object[] {value!}); - } - catch (Exception ex) - { - throw CliFxException.CannotConvertToType(this, value, targetType, ex); - } - - throw CliFxException.CannotConvertToType(this, value, targetType); - } - - private object ConvertNonScalar( - IReadOnlyList values, - Type targetEnumerableType, - Type targetElementType) - { - var array = values - .Select(v => ConvertScalar(v, targetElementType)) - .ToNonGenericArray(targetElementType); - - var arrayType = array.GetType(); - - // Assignable from an array - if (targetEnumerableType.IsAssignableFrom(arrayType)) - return array; - - // Constructible from an array - var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType}); - if (arrayConstructor != null) - return arrayConstructor.Invoke(new object[] {array}); - - throw CliFxException.CannotConvertNonScalar(this, values, targetEnumerableType); - } - - private object? Convert(IReadOnlyList values) - { - // Short-circuit built-in arguments - if (Property == null) - return null; - - var targetType = Property.PropertyType; - var enumerableUnderlyingType = TryGetEnumerableArgumentUnderlyingType(); - - // Scalar - if (enumerableUnderlyingType == null) - { - return values.Count <= 1 - ? ConvertScalar(values.SingleOrDefault(), targetType) - : throw CliFxException.CannotConvertMultipleValuesToNonScalar(this, values); - } - // Non-scalar - else - { - return ConvertNonScalar(values, targetType, enumerableUnderlyingType); - } - } - - private void Validate(object? value) - { - if (value is null) - return; - - var validators = ValidatorTypes - .Select(t => t.CreateInstance()) - .ToArray(); - - var failedValidations = validators - .Select(v => v.Validate(value)) - .Where(result => !result.IsValid) - .ToArray(); - - if (failedValidations.Any()) - throw CliFxException.ValidationFailed(this, failedValidations); - } - - public void BindOn(ICommand command, IReadOnlyList values) - { - var value = Convert(values); - Validate(value); - - Property?.SetValue(command, value); - } - - public void BindOn(ICommand command, params string[] values) => - BindOn(command, (IReadOnlyList) values); - - public IReadOnlyList GetValidValues() - { - if (Property == null) - return Array.Empty(); - - var underlyingType = - Property.PropertyType.TryGetNullableUnderlyingType() ?? - Property.PropertyType; - - // Enum - if (underlyingType.IsEnum) - return Enum.GetNames(underlyingType); - - return Array.Empty(); - } - } - - internal partial class CommandArgumentSchema - { - private static readonly IFormatProvider FormatProvider = CultureInfo.InvariantCulture; - - private static readonly IReadOnlyDictionary> PrimitiveConverters = - new Dictionary> - { - [typeof(char)] = v => v.Single(), - [typeof(sbyte)] = v => sbyte.Parse(v, FormatProvider), - [typeof(byte)] = v => byte.Parse(v, FormatProvider), - [typeof(short)] = v => short.Parse(v, FormatProvider), - [typeof(ushort)] = v => ushort.Parse(v, FormatProvider), - [typeof(int)] = v => int.Parse(v, FormatProvider), - [typeof(uint)] = v => uint.Parse(v, FormatProvider), - [typeof(long)] = v => long.Parse(v, FormatProvider), - [typeof(ulong)] = v => ulong.Parse(v, FormatProvider), - [typeof(float)] = v => float.Parse(v, FormatProvider), - [typeof(double)] = v => double.Parse(v, FormatProvider), - [typeof(decimal)] = v => decimal.Parse(v, FormatProvider), - [typeof(DateTime)] = v => DateTime.Parse(v, FormatProvider), - [typeof(DateTimeOffset)] = v => DateTimeOffset.Parse(v, FormatProvider), - [typeof(TimeSpan)] = v => TimeSpan.Parse(v, FormatProvider), - }; - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandDirectiveInput.cs b/CliFx/Domain/CommandDirectiveInput.cs deleted file mode 100644 index fab3bbd..0000000 --- a/CliFx/Domain/CommandDirectiveInput.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CliFx.Domain -{ - internal class CommandDirectiveInput - { - public string Name { get; } - - public bool IsDebugDirective => string.Equals(Name, "debug", StringComparison.OrdinalIgnoreCase); - - public bool IsPreviewDirective => string.Equals(Name, "preview", StringComparison.OrdinalIgnoreCase); - - public CommandDirectiveInput(string name) => Name = name; - - [ExcludeFromCodeCoverage] - public override string ToString() => $"[{Name}]"; - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandInput.cs b/CliFx/Domain/CommandInput.cs deleted file mode 100644 index 32cbf0b..0000000 --- a/CliFx/Domain/CommandInput.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal partial class CommandInput - { - public IReadOnlyList Directives { get; } - - public string? CommandName { get; } - - public IReadOnlyList Parameters { get; } - - public IReadOnlyList Options { get; } - - public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective); - - public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective); - - public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption); - - public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption); - - public CommandInput( - IReadOnlyList directives, - string? commandName, - IReadOnlyList parameters, - IReadOnlyList options) - { - Directives = directives; - CommandName = commandName; - Parameters = parameters; - Options = options; - } - - [ExcludeFromCodeCoverage] - public override string ToString() - { - var buffer = new StringBuilder(); - - foreach (var directive in Directives) - { - buffer - .AppendIfNotEmpty(' ') - .Append(directive); - } - - if (!string.IsNullOrWhiteSpace(CommandName)) - { - buffer - .AppendIfNotEmpty(' ') - .Append(CommandName); - } - - foreach (var parameter in Parameters) - { - buffer - .AppendIfNotEmpty(' ') - .Append(parameter); - } - - foreach (var option in Options) - { - buffer - .AppendIfNotEmpty(' ') - .Append(option); - } - - return buffer.ToString(); - } - } - - internal partial class CommandInput - { - private static IReadOnlyList ParseDirectives( - IReadOnlyList commandLineArguments, - ref int index) - { - var result = new List(); - - for (; index < commandLineArguments.Count; index++) - { - var argument = commandLineArguments[index]; - - if (!argument.StartsWith('[') || !argument.EndsWith(']')) - break; - - var name = argument.Substring(1, argument.Length - 2); - result.Add(new CommandDirectiveInput(name)); - } - - return result; - } - - private static string? ParseCommandName( - IReadOnlyList commandLineArguments, - ISet commandNames, - ref int index) - { - var buffer = new List(); - - var commandName = default(string?); - var lastIndex = index; - - // We need to look ahead to see if we can match as many consecutive arguments to a command name as possible - for (var i = index; i < commandLineArguments.Count; i++) - { - var argument = commandLineArguments[i]; - buffer.Add(argument); - - var potentialCommandName = buffer.JoinToString(" "); - - if (commandNames.Contains(potentialCommandName)) - { - commandName = potentialCommandName; - lastIndex = i; - } - } - - // Update the index only if command name was found in the arguments - if (!string.IsNullOrWhiteSpace(commandName)) - index = lastIndex + 1; - - return commandName; - } - - private static IReadOnlyList ParseParameters( - IReadOnlyList commandLineArguments, - ref int index) - { - var result = new List(); - - for (; index < commandLineArguments.Count; index++) - { - var argument = commandLineArguments[index]; - - var isOptionArgument = - argument.StartsWith("--", StringComparison.OrdinalIgnoreCase) && - argument.Length > 2 && - char.IsLetter(argument[2]) || - argument.StartsWith('-') && - argument.Length > 1 && - char.IsLetter(argument[1]); - - // Break on the first encountered option - if (isOptionArgument) - break; - - result.Add(new CommandParameterInput(argument)); - } - - return result; - } - - private static IReadOnlyList ParseOptions( - IReadOnlyList commandLineArguments, - ref int index) - { - var result = new List(); - - var currentOptionAlias = default(string?); - var currentOptionValues = new List(); - - for (; index < commandLineArguments.Count; index++) - { - var argument = commandLineArguments[index]; - - // Name - if (argument.StartsWith("--", StringComparison.Ordinal) && - argument.Length > 2 && - char.IsLetter(argument[2])) - { - // Flush previous - if (!string.IsNullOrWhiteSpace(currentOptionAlias)) - result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues)); - - currentOptionAlias = argument.Substring(2); - currentOptionValues = new List(); - } - // Short name - else if (argument.StartsWith('-') && - argument.Length > 1 && - char.IsLetter(argument[1])) - { - foreach (var alias in argument.Substring(1)) - { - // Flush previous - if (!string.IsNullOrWhiteSpace(currentOptionAlias)) - result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues)); - - currentOptionAlias = alias.AsString(); - currentOptionValues = new List(); - } - } - // Value - else if (!string.IsNullOrWhiteSpace(currentOptionAlias)) - { - currentOptionValues.Add(argument); - } - } - - // Flush last option - if (!string.IsNullOrWhiteSpace(currentOptionAlias)) - result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues)); - - return result; - } - - public static CommandInput Parse(IReadOnlyList commandLineArguments, IReadOnlyList availableCommandNames) - { - var availableCommandNamesSet = availableCommandNames.ToHashSet(StringComparer.OrdinalIgnoreCase); - - var index = 0; - - var directives = ParseDirectives( - commandLineArguments, - ref index - ); - - var commandName = ParseCommandName( - commandLineArguments, - availableCommandNamesSet, - ref index - ); - - var parameters = ParseParameters( - commandLineArguments, - ref index - ); - - var options = ParseOptions( - commandLineArguments, - ref index - ); - - return new CommandInput(directives, commandName, parameters, options); - } - } - - internal partial class CommandInput - { - public static CommandInput Empty { get; } = new( - Array.Empty(), - null, - Array.Empty(), - Array.Empty() - ); - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandOptionInput.cs b/CliFx/Domain/CommandOptionInput.cs deleted file mode 100644 index 9ed3545..0000000 --- a/CliFx/Domain/CommandOptionInput.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal class CommandOptionInput - { - public string Alias { get; } - - public IReadOnlyList Values { get; } - - public bool IsHelpOption => CommandOptionSchema.HelpOption.MatchesNameOrShortName(Alias); - - public bool IsVersionOption => CommandOptionSchema.VersionOption.MatchesNameOrShortName(Alias); - - public CommandOptionInput(string alias, IReadOnlyList values) - { - Alias = alias; - Values = values; - } - - public string GetRawAlias() => Alias switch - { - {Length: 0} => Alias, - {Length: 1} => $"-{Alias}", - _ => $"--{Alias}" - }; - - public string GetRawValues() => Values.Select(v => v.Quote()).JoinToString(" "); - - [ExcludeFromCodeCoverage] - public override string ToString() => $"{GetRawAlias()} {GetRawValues()}"; - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandOptionSchema.cs b/CliFx/Domain/CommandOptionSchema.cs deleted file mode 100644 index f35e4a6..0000000 --- a/CliFx/Domain/CommandOptionSchema.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Text; -using CliFx.Attributes; - -namespace CliFx.Domain -{ - internal partial class CommandOptionSchema : CommandArgumentSchema - { - public string? Name { get; } - - public char? ShortName { get; } - - public string? EnvironmentVariableName { get; } - - public bool IsRequired { get; } - - public CommandOptionSchema( - PropertyInfo? property, - string? name, - char? shortName, - string? environmentVariableName, - bool isRequired, - string? description, - Type? converterType, - Type[] validatorTypes) - : base(property, description, converterType, validatorTypes) - { - Name = name; - ShortName = shortName; - EnvironmentVariableName = environmentVariableName; - IsRequired = isRequired; - } - - public bool MatchesName(string? name) => - !string.IsNullOrWhiteSpace(Name) && - string.Equals(Name, name, StringComparison.OrdinalIgnoreCase); - - public bool MatchesShortName(char? shortName) => - ShortName != null && - ShortName == shortName; - - public bool MatchesNameOrShortName(string alias) => - MatchesName(alias) || - alias.Length == 1 && MatchesShortName(alias.Single()); - - public bool MatchesEnvironmentVariableName(string environmentVariableName) => - !string.IsNullOrWhiteSpace(EnvironmentVariableName) && - string.Equals(EnvironmentVariableName, environmentVariableName, StringComparison.Ordinal); - - public string GetUserFacingDisplayString() - { - var buffer = new StringBuilder(); - - if (!string.IsNullOrWhiteSpace(Name)) - { - buffer - .Append("--") - .Append(Name); - } - - if (!string.IsNullOrWhiteSpace(Name) && ShortName != null) - { - buffer.Append('|'); - } - - if (ShortName != null) - { - buffer - .Append('-') - .Append(ShortName); - } - - return buffer.ToString(); - } - - public string GetInternalDisplayString() => $"{Property?.Name ?? ""} ('{GetUserFacingDisplayString()}')"; - - [ExcludeFromCodeCoverage] - public override string ToString() => GetInternalDisplayString(); - } - - internal partial class CommandOptionSchema - { - public static CommandOptionSchema? TryResolve(PropertyInfo property) - { - var attribute = property.GetCustomAttribute(); - if (attribute == null) - return null; - - // The user may mistakenly specify dashes, thinking it's required, so trim them - var name = attribute.Name?.TrimStart('-'); - - return new CommandOptionSchema( - property, - name, - attribute.ShortName, - attribute.EnvironmentVariableName, - attribute.IsRequired, - attribute.Description, - attribute.Converter, - attribute.Validators - ); - } - } - - internal partial class CommandOptionSchema - { - public static CommandOptionSchema HelpOption { get; } = new( - null, - "help", - 'h', - null, - false, - "Shows help text.", - null, - Array.Empty() - ); - - public static CommandOptionSchema VersionOption { get; } = new( - null, - "version", - null, - null, - false, - "Shows version information.", - null, - Array.Empty() - ); - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandParameterInput.cs b/CliFx/Domain/CommandParameterInput.cs deleted file mode 100644 index 2650231..0000000 --- a/CliFx/Domain/CommandParameterInput.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace CliFx.Domain -{ - internal class CommandParameterInput - { - public string Value { get; } - - public CommandParameterInput(string value) => Value = value; - - [ExcludeFromCodeCoverage] - public override string ToString() => Value; - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandParameterSchema.cs b/CliFx/Domain/CommandParameterSchema.cs deleted file mode 100644 index 9283186..0000000 --- a/CliFx/Domain/CommandParameterSchema.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Text; -using CliFx.Attributes; - -namespace CliFx.Domain -{ - internal partial class CommandParameterSchema : CommandArgumentSchema - { - public int Order { get; } - - public string Name { get; } - - public CommandParameterSchema( - PropertyInfo? property, - int order, - string name, - string? description, - Type? converterType, - Type[] validatorTypes) - : base(property, description, converterType, validatorTypes) - { - Order = order; - Name = name; - } - - public string GetUserFacingDisplayString() - { - var buffer = new StringBuilder(); - - buffer - .Append('<') - .Append(Name) - .Append('>'); - - return buffer.ToString(); - } - - public string GetInternalDisplayString() => $"{Property?.Name ?? ""} ([{Order}] {GetUserFacingDisplayString()})"; - - [ExcludeFromCodeCoverage] - public override string ToString() => GetInternalDisplayString(); - } - - internal partial class CommandParameterSchema - { - public static CommandParameterSchema? TryResolve(PropertyInfo property) - { - var attribute = property.GetCustomAttribute(); - if (attribute == null) - return null; - - var name = attribute.Name ?? property.Name.ToLowerInvariant(); - - return new CommandParameterSchema( - property, - attribute.Order, - name, - attribute.Description, - attribute.Converter, - attribute.Validators - ); - } - } -} \ No newline at end of file diff --git a/CliFx/Domain/CommandSchema.cs b/CliFx/Domain/CommandSchema.cs deleted file mode 100644 index 7fd21c1..0000000 --- a/CliFx/Domain/CommandSchema.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using CliFx.Attributes; -using CliFx.Exceptions; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal partial class CommandSchema - { - public Type Type { get; } - - public string? Name { get; } - - public bool IsDefault => string.IsNullOrWhiteSpace(Name); - - public string? Description { get; } - - public IReadOnlyList Parameters { get; } - - public IReadOnlyList Options { get; } - - public bool IsHelpOptionAvailable => Options.Contains(CommandOptionSchema.HelpOption); - - public bool IsVersionOptionAvailable => Options.Contains(CommandOptionSchema.VersionOption); - - public CommandSchema( - Type type, - string? name, - string? description, - IReadOnlyList parameters, - IReadOnlyList options) - { - Type = type; - Name = name; - Description = description; - Parameters = parameters; - Options = options; - } - - public bool MatchesName(string? name) => - !string.IsNullOrWhiteSpace(Name) - ? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase) - : string.IsNullOrWhiteSpace(name); - - public IEnumerable GetArguments() - { - foreach (var parameter in Parameters) - yield return parameter; - - foreach (var option in Options) - yield return option; - } - - public IReadOnlyDictionary GetArgumentValues(ICommand instance) - { - var result = new Dictionary(); - - foreach (var argument in GetArguments()) - { - // Skip built-in arguments - if (argument.Property == null) - continue; - - var value = argument.Property.GetValue(instance); - result[argument] = value; - } - - return result; - } - - private void BindParameters(ICommand instance, IReadOnlyList parameterInputs) - { - // All inputs must be bound - var remainingParameterInputs = parameterInputs.ToList(); - - // Scalar parameters - var scalarParameters = Parameters - .OrderBy(p => p.Order) - .TakeWhile(p => p.IsScalar) - .ToArray(); - - for (var i = 0; i < scalarParameters.Length; i++) - { - var parameter = scalarParameters[i]; - - var scalarInput = i < parameterInputs.Count - ? parameterInputs[i] - : throw CliFxException.ParameterNotSet(parameter); - - parameter.BindOn(instance, scalarInput.Value); - remainingParameterInputs.Remove(scalarInput); - } - - // Non-scalar parameter (only one is allowed) - var nonScalarParameter = Parameters - .OrderBy(p => p.Order) - .FirstOrDefault(p => !p.IsScalar); - - if (nonScalarParameter != null) - { - var nonScalarValues = parameterInputs - .Skip(scalarParameters.Length) - .Select(p => p.Value) - .ToArray(); - - // Parameters are required by default and so a non-scalar parameter must - // be bound to at least one value - if(!nonScalarValues.Any()) - throw CliFxException.ParameterNotSet(nonScalarParameter); - - nonScalarParameter.BindOn(instance, nonScalarValues); - remainingParameterInputs.Clear(); - } - - // Ensure all inputs were bound - if (remainingParameterInputs.Any()) - throw CliFxException.UnrecognizedParametersProvided(remainingParameterInputs); - } - - private void BindOptions( - ICommand instance, - IReadOnlyList optionInputs, - IReadOnlyDictionary environmentVariables) - { - // All inputs must be bound - var remainingOptionInputs = optionInputs.ToList(); - - // All required options must be set - var unsetRequiredOptions = Options.Where(o => o.IsRequired).ToList(); - - // Environment variables - foreach (var (name, value) in environmentVariables) - { - var option = Options.FirstOrDefault(o => o.MatchesEnvironmentVariableName(name)); - if (option == null) - continue; - - var values = option.IsScalar - ? new[] {value} - : value.Split(Path.PathSeparator); - - option.BindOn(instance, values); - unsetRequiredOptions.Remove(option); - } - - // Direct input - foreach (var option in Options) - { - var inputs = optionInputs - .Where(i => option.MatchesNameOrShortName(i.Alias)) - .ToArray(); - - // Skip if the inputs weren't provided for this option - if (!inputs.Any()) - continue; - - var inputValues = inputs.SelectMany(i => i.Values).ToArray(); - option.BindOn(instance, inputValues); - - remainingOptionInputs.RemoveRange(inputs); - - // Required option implies that the value has to be set and also be non-empty - if (inputValues.Any()) - unsetRequiredOptions.Remove(option); - } - - // Ensure all inputs were bound - if (remainingOptionInputs.Any()) - throw CliFxException.UnrecognizedOptionsProvided(remainingOptionInputs); - - // Ensure all required options were set - if (unsetRequiredOptions.Any()) - throw CliFxException.RequiredOptionsNotSet(unsetRequiredOptions); - } - - public void Bind( - ICommand instance, - CommandInput input, - IReadOnlyDictionary environmentVariables) - { - BindParameters(instance, input.Parameters); - BindOptions(instance, input.Options, environmentVariables); - } - - public string GetInternalDisplayString() - { - var buffer = new StringBuilder(); - - // Type - buffer.Append(Type.FullName); - - // Name - buffer - .Append(' ') - .Append('(') - .Append(IsDefault - ? "" - : $"'{Name}'" - ) - .Append(')'); - - return buffer.ToString(); - } - - [ExcludeFromCodeCoverage] - public override string ToString() => GetInternalDisplayString(); - } - - internal partial class CommandSchema - { - public static bool IsCommandType(Type type) => - type.Implements(typeof(ICommand)) && - type.IsDefined(typeof(CommandAttribute)) && - !type.IsAbstract && - !type.IsInterface; - - public static CommandSchema? TryResolve(Type type) - { - if (!IsCommandType(type)) - return null; - - var attribute = type.GetCustomAttribute(); - - var name = attribute?.Name; - - var builtInOptions = string.IsNullOrWhiteSpace(name) - ? new[] {CommandOptionSchema.HelpOption, CommandOptionSchema.VersionOption} - : new[] {CommandOptionSchema.HelpOption}; - - var parameters = type.GetProperties() - .Select(CommandParameterSchema.TryResolve) - .Where(p => p != null) - .ToArray(); - - var options = type.GetProperties() - .Select(CommandOptionSchema.TryResolve) - .Where(o => o != null) - .Concat(builtInOptions) - .ToArray(); - - return new CommandSchema( - type, - name, - attribute?.Description, - parameters!, - options! - ); - } - } -} \ No newline at end of file diff --git a/CliFx/Domain/HelpTextWriter.cs b/CliFx/Domain/HelpTextWriter.cs deleted file mode 100644 index b7528c6..0000000 --- a/CliFx/Domain/HelpTextWriter.cs +++ /dev/null @@ -1,411 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal partial class HelpTextWriter - { - private readonly ApplicationMetadata _metadata; - private readonly IConsole _console; - - private int _column; - private int _row; - - private bool IsEmpty => _column == 0 && _row == 0; - - public HelpTextWriter(ApplicationMetadata metadata, IConsole console) - { - _metadata = metadata; - _console = console; - } - - private void Write(char value) - { - _console.Output.Write(value); - _column++; - } - - private void Write(string value) - { - _console.Output.Write(value); - _column += value.Length; - } - - private void Write(ConsoleColor foregroundColor, string value) - { - _console.WithForegroundColor(foregroundColor, () => Write(value)); - } - - private void WriteLine() - { - _console.Output.WriteLine(); - _column = 0; - _row++; - } - - private void WriteVerticalMargin(int size = 1) - { - for (var i = 0; i < size; i++) - WriteLine(); - } - - private void WriteHorizontalMargin(int size = 2) - { - for (var i = 0; i < size; i++) - Write(' '); - } - - private void WriteColumnMargin(int columnSize = 20, int offsetSize = 2) - { - if (_column + offsetSize < columnSize) - WriteHorizontalMargin(columnSize - _column); - else - WriteHorizontalMargin(offsetSize); - } - - private void WriteHeader(string text) - { - Write(ConsoleColor.Magenta, text); - WriteLine(); - } - - private void WriteApplicationInfo() - { - // Title and version - Write(ConsoleColor.Yellow, _metadata.Title); - Write(' '); - Write(ConsoleColor.Yellow, _metadata.VersionText); - WriteLine(); - - // Description - if (!string.IsNullOrWhiteSpace(_metadata.Description)) - { - WriteHorizontalMargin(); - Write(_metadata.Description); - WriteLine(); - } - } - - private void WriteCommandDescription(CommandSchema command) - { - if (string.IsNullOrWhiteSpace(command.Description)) - return; - - if (!IsEmpty) - WriteVerticalMargin(); - - WriteHeader("Description"); - - WriteHorizontalMargin(); - Write(command.Description); - WriteLine(); - } - - private void WriteCommandUsageLineItem(CommandSchema command, bool showChildCommandsPlaceholder) - { - // Command name - if (!string.IsNullOrWhiteSpace(command.Name)) - { - Write(ConsoleColor.Cyan, command.Name); - Write(' '); - } - - // Child command placeholder - if (showChildCommandsPlaceholder) - { - Write(ConsoleColor.Cyan, "[command]"); - Write(' '); - } - - // Parameters - foreach (var parameter in command.Parameters) - { - Write(parameter.IsScalar - ? $"<{parameter.Name}>" - : $"<{parameter.Name}...>" - ); - Write(' '); - } - - // Required options - foreach (var option in command.Options.Where(o => o.IsRequired)) - { - Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(option.Name) - ? $"--{option.Name}" - : $"-{option.ShortName}" - ); - Write(' '); - - Write(option.IsScalar - ? "" - : "" - ); - Write(' '); - } - - // Options placeholder - Write(ConsoleColor.White, "[options]"); - - WriteLine(); - } - - private void WriteCommandUsage( - CommandSchema command, - IReadOnlyList childCommands) - { - if (!IsEmpty) - WriteVerticalMargin(); - - WriteHeader("Usage"); - - // Exe name - WriteHorizontalMargin(); - Write(_metadata.ExecutableName); - Write(' '); - - // Current command usage - WriteCommandUsageLineItem(command, childCommands.Any()); - - // Sub commands usage - if (childCommands.Any()) - { - WriteVerticalMargin(); - - foreach (var childCommand in childCommands) - { - WriteHorizontalMargin(); - Write("... "); - WriteCommandUsageLineItem(childCommand, false); - } - } - } - - private void WriteCommandParameters(CommandSchema command) - { - if (!command.Parameters.Any()) - return; - - if (!IsEmpty) - WriteVerticalMargin(); - - WriteHeader("Parameters"); - - foreach (var parameter in command.Parameters.OrderBy(p => p.Order)) - { - Write(ConsoleColor.Red, "* "); - Write(ConsoleColor.White, $"{parameter.Name}"); - - WriteColumnMargin(); - - // Description - if (!string.IsNullOrWhiteSpace(parameter.Description)) - { - Write(parameter.Description); - Write(' '); - } - - // Valid values - var validValues = parameter.GetValidValues(); - if (validValues.Any()) - { - Write($"Valid values: {FormatValidValues(validValues)}."); - } - - WriteLine(); - } - } - - private void WriteCommandOptions( - CommandSchema command, - IReadOnlyDictionary argumentDefaultValues) - { - if (!IsEmpty) - WriteVerticalMargin(); - - WriteHeader("Options"); - - foreach (var option in command.Options.OrderByDescending(o => o.IsRequired)) - { - if (option.IsRequired) - { - Write(ConsoleColor.Red, "* "); - } - else - { - WriteHorizontalMargin(); - } - - // Short name - if (option.ShortName != null) - { - Write(ConsoleColor.White, $"-{option.ShortName}"); - } - - // Separator - if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName != null) - { - Write('|'); - } - - // Name - if (!string.IsNullOrWhiteSpace(option.Name)) - { - Write(ConsoleColor.White, $"--{option.Name}"); - } - - WriteColumnMargin(); - - // Description - if (!string.IsNullOrWhiteSpace(option.Description)) - { - Write(option.Description); - Write(' '); - } - - // Valid values - var validValues = option.GetValidValues(); - if (validValues.Any()) - { - Write($"Valid values: {FormatValidValues(validValues)}."); - Write(' '); - } - - // Environment variable - if (!string.IsNullOrWhiteSpace(option.EnvironmentVariableName)) - { - Write($"Environment variable: \"{option.EnvironmentVariableName}\"."); - Write(' '); - } - - // Default value - if (!option.IsRequired) - { - var defaultValue = argumentDefaultValues.GetValueOrDefault(option); - var defaultValueFormatted = TryFormatDefaultValue(defaultValue); - if (defaultValueFormatted != null) - { - Write($"Default: {defaultValueFormatted}."); - } - } - - WriteLine(); - } - } - - private void WriteCommandChildren( - CommandSchema command, - IReadOnlyList childCommands) - { - if (!childCommands.Any()) - return; - - if (!IsEmpty) - WriteVerticalMargin(); - - WriteHeader("Commands"); - - foreach (var childCommand in childCommands) - { - var relativeCommandName = !string.IsNullOrWhiteSpace(command.Name) - ? childCommand.Name!.Substring(command.Name.Length).Trim() - : childCommand.Name!; - - // Name - WriteHorizontalMargin(); - Write(ConsoleColor.Cyan, relativeCommandName); - - // Description - if (!string.IsNullOrWhiteSpace(childCommand.Description)) - { - WriteColumnMargin(); - Write(childCommand.Description); - } - - WriteLine(); - } - - // Child command help tip - WriteVerticalMargin(); - Write("You can run `"); - Write(_metadata.ExecutableName); - - if (!string.IsNullOrWhiteSpace(command.Name)) - { - Write(' '); - Write(ConsoleColor.Cyan, command.Name); - } - - Write(' '); - Write(ConsoleColor.Cyan, "[command]"); - - Write(' '); - Write(ConsoleColor.White, "--help"); - - Write("` to show help on a specific command."); - - WriteLine(); - } - - public void Write( - RootSchema root, - CommandSchema command, - IReadOnlyDictionary defaultValues) - { - var commandName = command.Name; - var childCommands = root.GetChildCommands(commandName); - var descendantCommands = root.GetDescendantCommands(commandName); - - _console.ResetColor(); - - if (command.IsDefault) - WriteApplicationInfo(); - - WriteCommandDescription(command); - WriteCommandUsage(command, descendantCommands); - WriteCommandParameters(command); - WriteCommandOptions(command, defaultValues); - WriteCommandChildren(command, childCommands); - } - } - - internal partial class HelpTextWriter - { - private static string FormatValidValues(IReadOnlyList values) => - values.Select(v => v.Quote()).JoinToString(", "); - - private static string? TryFormatDefaultValue(object? defaultValue) - { - if (defaultValue == null) - return null; - - // Enumerable - if (!(defaultValue is string) && defaultValue is IEnumerable defaultValues) - { - var elementType = defaultValues.GetType().TryGetEnumerableUnderlyingType() ?? typeof(object); - - // If the ToString() method is not overriden, the default value can't be formatted nicely - if (!elementType.IsToStringOverriden()) - return null; - - return defaultValues - .Cast() - .Where(o => o != null) - .Select(o => o!.ToFormattableString(CultureInfo.InvariantCulture).Quote()) - .JoinToString(" "); - } - // Non-enumerable - else - { - // If the ToString() method is not overriden, the default value can't be formatted nicely - if (!defaultValue.GetType().IsToStringOverriden()) - return null; - - return defaultValue.ToFormattableString(CultureInfo.InvariantCulture).Quote(); - } - } - } -} \ No newline at end of file diff --git a/CliFx/Domain/RootSchema.cs b/CliFx/Domain/RootSchema.cs deleted file mode 100644 index 53b7ebf..0000000 --- a/CliFx/Domain/RootSchema.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Exceptions; -using CliFx.Internal.Extensions; - -namespace CliFx.Domain -{ - internal partial class RootSchema - { - public IReadOnlyList Commands { get; } - - public RootSchema(IReadOnlyList commands) - { - Commands = commands; - } - - public IReadOnlyList GetCommandNames() => Commands - .Select(c => c.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .ToArray()!; - - public CommandSchema? TryFindDefaultCommand() => - Commands.FirstOrDefault(c => c.IsDefault); - - public CommandSchema? TryFindCommand(string? commandName) => - Commands.FirstOrDefault(c => c.MatchesName(commandName)); - - private IReadOnlyList GetDescendantCommands( - IReadOnlyList potentialParentCommands, - string? parentCommandName) => - potentialParentCommands - // Default commands can't be children of anything - .Where(c => !string.IsNullOrWhiteSpace(c.Name)) - // Command can't be its own child - .Where(c => !c.MatchesName(parentCommandName)) - .Where(c => - string.IsNullOrWhiteSpace(parentCommandName) || - c.Name!.StartsWith(parentCommandName + ' ', StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - public IReadOnlyList GetDescendantCommands(string? parentCommandName) => - GetDescendantCommands(Commands, parentCommandName); - - public IReadOnlyList GetChildCommands(string? parentCommandName) - { - var descendants = GetDescendantCommands(parentCommandName); - - // Filter out descendants of descendants, leave only children - var result = new List(descendants); - - foreach (var descendant in descendants) - { - var descendantDescendants = GetDescendantCommands(descendants, descendant.Name); - result.RemoveRange(descendantDescendants); - } - - return result; - } - } - - internal partial class RootSchema - { - private static void ValidateParameters(CommandSchema command) - { - var duplicateOrderGroup = command.Parameters - .GroupBy(a => a.Order) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateOrderGroup != null) - { - throw CliFxException.ParametersWithSameOrder( - command, - duplicateOrderGroup.Key, - duplicateOrderGroup.ToArray() - ); - } - - var duplicateNameGroup = command.Parameters - .Where(a => !string.IsNullOrWhiteSpace(a.Name)) - .GroupBy(a => a.Name!, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateNameGroup != null) - { - throw CliFxException.ParametersWithSameName( - command, - duplicateNameGroup.Key, - duplicateNameGroup.ToArray() - ); - } - - var nonScalarParameters = command.Parameters - .Where(p => !p.IsScalar) - .ToArray(); - - if (nonScalarParameters.Length > 1) - { - throw CliFxException.TooManyNonScalarParameters( - command, - nonScalarParameters - ); - } - - var nonLastNonScalarParameter = command.Parameters - .OrderByDescending(a => a.Order) - .Skip(1) - .LastOrDefault(p => !p.IsScalar); - - if (nonLastNonScalarParameter != null) - { - throw CliFxException.NonLastNonScalarParameter( - command, - nonLastNonScalarParameter - ); - } - - var invalidConverterParameters = command.Parameters - .Where(p => p.ConverterType != null && !p.ConverterType.Implements(typeof(IArgumentValueConverter))) - .ToArray(); - - if (invalidConverterParameters.Any()) - { - throw CliFxException.ParametersWithInvalidConverters( - command, - invalidConverterParameters - ); - } - - var invalidValidatorParameters = command.Parameters - .Where(p => !p.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator)))) - .ToArray(); - - if (invalidValidatorParameters.Any()) - { - throw CliFxException.ParametersWithInvalidValidators( - command, - invalidValidatorParameters - ); - } - } - - private static void ValidateOptions(CommandSchema command) - { - var noNameGroup = command.Options - .Where(o => o.ShortName == null && string.IsNullOrWhiteSpace(o.Name)) - .ToArray(); - - if (noNameGroup.Any()) - { - throw CliFxException.OptionsWithNoName( - command, - noNameGroup.ToArray() - ); - } - - var invalidLengthNameGroup = command.Options - .Where(o => !string.IsNullOrWhiteSpace(o.Name)) - .Where(o => o.Name!.Length <= 1) - .ToArray(); - - if (invalidLengthNameGroup.Any()) - { - throw CliFxException.OptionsWithInvalidLengthName( - command, - invalidLengthNameGroup - ); - } - - var duplicateNameGroup = command.Options - .Where(o => !string.IsNullOrWhiteSpace(o.Name)) - .GroupBy(o => o.Name!, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateNameGroup != null) - { - throw CliFxException.OptionsWithSameName( - command, - duplicateNameGroup.Key, - duplicateNameGroup.ToArray() - ); - } - - var duplicateShortNameGroup = command.Options - .Where(o => o.ShortName != null) - .GroupBy(o => o.ShortName!.Value) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateShortNameGroup != null) - { - throw CliFxException.OptionsWithSameShortName( - command, - duplicateShortNameGroup.Key, - duplicateShortNameGroup.ToArray() - ); - } - - var duplicateEnvironmentVariableNameGroup = command.Options - .Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName)) - .GroupBy(o => o.EnvironmentVariableName!, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateEnvironmentVariableNameGroup != null) - { - throw CliFxException.OptionsWithSameEnvironmentVariableName( - command, - duplicateEnvironmentVariableNameGroup.Key, - duplicateEnvironmentVariableNameGroup.ToArray() - ); - } - - var invalidConverterOptions = command.Options - .Where(o => o.ConverterType != null && !o.ConverterType.Implements(typeof(IArgumentValueConverter))) - .ToArray(); - - if (invalidConverterOptions.Any()) - { - throw CliFxException.OptionsWithInvalidConverters( - command, - invalidConverterOptions - ); - } - - var invalidValidatorOptions = command.Options - .Where(o => !o.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator)))) - .ToArray(); - - if (invalidValidatorOptions.Any()) - { - throw CliFxException.OptionsWithInvalidValidators( - command, - invalidValidatorOptions - ); - } - - var nonLetterFirstCharacterInNameOptions = command.Options - .Where(o => !string.IsNullOrWhiteSpace(o.Name) && !char.IsLetter(o.Name[0])) - .ToArray(); - - if (nonLetterFirstCharacterInNameOptions.Any()) - { - throw CliFxException.OptionsWithNonLetterCharacterName( - command, - nonLetterFirstCharacterInNameOptions - ); - } - - var nonLetterShortNameOptions = command.Options - .Where(o => o.ShortName != null && !char.IsLetter(o.ShortName.Value)) - .ToArray(); - - if (nonLetterShortNameOptions.Any()) - { - throw CliFxException.OptionsWithNonLetterCharacterShortName( - command, - nonLetterShortNameOptions - ); - } - } - - private static void ValidateCommands(IReadOnlyList commands) - { - if (!commands.Any()) - { - throw CliFxException.NoCommandsDefined(); - } - - var duplicateNameGroup = commands - .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicateNameGroup != null) - { - throw !string.IsNullOrWhiteSpace(duplicateNameGroup.Key) - ? CliFxException.CommandsWithSameName( - duplicateNameGroup.Key, - duplicateNameGroup.ToArray() - ) - : CliFxException.TooManyDefaultCommands(duplicateNameGroup.ToArray()); - } - } - - public static RootSchema Resolve(IReadOnlyList commandTypes) - { - var commands = new List(); - - foreach (var commandType in commandTypes) - { - var command = - CommandSchema.TryResolve(commandType) ?? - throw CliFxException.InvalidCommandType(commandType); - - ValidateParameters(command); - ValidateOptions(command); - - commands.Add(command); - } - - ValidateCommands(commands); - - return new RootSchema(commands); - } - } -} \ No newline at end of file diff --git a/CliFx/Exceptions/CliFxException.cs b/CliFx/Exceptions/CliFxException.cs index 3a3fed5..c54e1ee 100644 --- a/CliFx/Exceptions/CliFxException.cs +++ b/CliFx/Exceptions/CliFxException.cs @@ -1,504 +1,54 @@ using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Attributes; -using CliFx.Domain; -using CliFx.Internal.Extensions; namespace CliFx.Exceptions { /// - /// Domain exception thrown within CliFx. + /// Exception thrown when there is an error during application execution. /// public partial class CliFxException : Exception { - private readonly bool _isMessageSet; + internal const int DefaultExitCode = 1; + + // Regular `exception.Message` never returns null, even if + // it hasn't been set. + internal string? ActualMessage { get; } + + /// + /// Returned exit code. + /// + public int ExitCode { get; } + + /// + /// Whether to show the help text before exiting. + /// + public bool ShowHelp { get; } /// /// Initializes an instance of . /// - public CliFxException(string? message, Exception? innerException = null) + public CliFxException( + string message, + int exitCode = DefaultExitCode, + bool showHelp = false, + Exception? innerException = null) : base(message, innerException) { - // Message property has a fallback so it's never empty, hence why we need this check - _isMessageSet = !string.IsNullOrWhiteSpace(message); - } - - /// - public override string ToString() => _isMessageSet - ? Message - : base.ToString(); - } - - // Internal exceptions - // Provide more diagnostic information here - public partial class CliFxException - { - internal static CliFxException DefaultActivatorFailed(Type type, Exception? innerException = null) - { - var configureActivatorMethodName = - $"{nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...)"; - - var message = $@" -Failed to create an instance of type '{type.FullName}'. -The type must have a public parameterless constructor in order to be instantiated by the default activator. - -To fix this, either make sure this type has a public parameterless constructor, or configure a custom activator using {configureActivatorMethodName}. -Refer to the readme to learn how to integrate a dependency container of your choice to act as a type activator."; - - return new CliFxException(message.Trim(), innerException); - } - - internal static CliFxException DelegateActivatorReturnedNull(Type type) - { - var message = $@" -Failed to create an instance of type '{type.FullName}', received instead. - -To fix this, ensure that the provided type activator was configured correctly, as it's not expected to return . -If you are using a dependency container, this error may signify that the type wasn't registered."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException InvalidCommandType(Type type) - { - var message = $@" -Command '{type.FullName}' is not a valid command type. - -In order to be a valid command type, it must: -- Not be an abstract class -- Implement {typeof(ICommand).FullName} -- Be annotated with {typeof(CommandAttribute).FullName} - -If you're experiencing problems, please refer to the readme for a quickstart example."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException NoCommandsDefined() - { - var message = $@" -There are no commands configured in the application. - -To fix this, ensure that at least one command is added through one of the methods on {nameof(CliApplicationBuilder)}. -If you're experiencing problems, please refer to the readme for a quickstart example."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException TooManyDefaultCommands(IReadOnlyList invalidCommands) - { - var message = $@" -Application configuration is invalid because there are {invalidCommands.Count} default commands: -{invalidCommands.JoinToString(Environment.NewLine)} - -There can only be one default command (i.e. command with no name) in an application. -Other commands must have unique non-empty names that identify them."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException CommandsWithSameName( - string name, - IReadOnlyList invalidCommands) - { - var message = $@" -Application configuration is invalid because there are {invalidCommands.Count} commands with the same name ('{name}'): -{invalidCommands.JoinToString(Environment.NewLine)} - -Commands must have unique names. -Names are not case-sensitive."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ParametersWithSameOrder( - CommandSchema command, - int order, - IReadOnlyList invalidParameters) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same order ({order}): -{invalidParameters.JoinToString(Environment.NewLine)} - -Parameters must have unique order."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ParametersWithSameName( - CommandSchema command, - string name, - IReadOnlyList invalidParameters) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same name ('{name}'): -{invalidParameters.JoinToString(Environment.NewLine)} - -Parameters must have unique names to avoid potential confusion in the help text. -Names are not case-sensitive."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException TooManyNonScalarParameters( - CommandSchema command, - IReadOnlyList invalidParameters) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} non-scalar parameters: -{invalidParameters.JoinToString(Environment.NewLine)} - -Non-scalar parameter is such that is bound from more than one value (e.g. array). -Only one parameter in a command may be non-scalar and it must be the last one in order. - -If it's not feasible to fit into these constraints, consider using options instead as they don't have these limitations."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException NonLastNonScalarParameter( - CommandSchema command, - CommandParameterSchema invalidParameter) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains a non-scalar parameter which is not the last in order: -{invalidParameter} - -Non-scalar parameter is such that is bound from more than one value (e.g. array). -Only one parameter in a command may be non-scalar and it must be the last one in order. - -If it's not feasible to fit into these constraints, consider using options instead as they don't have these limitations."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ParametersWithInvalidConverters( - CommandSchema command, - IReadOnlyList invalidParameters) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid converters: -{invalidParameters.JoinToString(Environment.NewLine)} - -Specified converter must implement {typeof(ArgumentValueConverter<>).FullName}."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ParametersWithInvalidValidators( - CommandSchema command, - IReadOnlyList invalidParameters) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid value validators: -{invalidParameters.JoinToString(Environment.NewLine)} - -Specified validators must inherit from {typeof(ArgumentValueValidator<>).FullName}."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithNoName( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains one or more options without a name: -{invalidOptions.JoinToString(Environment.NewLine)} - -Options must have either a name or a short name or both."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithInvalidLengthName( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains one or more options whose names are too short: -{invalidOptions.JoinToString(Environment.NewLine)} - -Option names must be at least 2 characters long to avoid confusion with short names. -If you intended to set the short name instead, use the attribute overload that accepts a char."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithSameName( - CommandSchema command, - string name, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same name ('{name}'): -{invalidOptions.JoinToString(Environment.NewLine)} - -Options must have unique names. -Names are not case-sensitive."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithSameShortName( - CommandSchema command, - char shortName, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same short name ('{shortName}'): -{invalidOptions.JoinToString(Environment.NewLine)} - -Options must have unique short names. -Short names are case-sensitive (i.e. 'a' and 'A' are different short names)."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithSameEnvironmentVariableName( - CommandSchema command, - string environmentVariableName, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same fallback environment variable name ('{environmentVariableName}'): -{invalidOptions.JoinToString(Environment.NewLine)} - -Options cannot share the same environment variable as a fallback. -Environment variable names are not case-sensitive."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithInvalidConverters( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid converters: -{invalidOptions.JoinToString(Environment.NewLine)} - -Specified converter must implement {typeof(ArgumentValueConverter<>).FullName}."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithInvalidValidators( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid validators: -{invalidOptions.JoinToString(Environment.NewLine)} - -Specified validators must inherit from {typeof(ArgumentValueValidator<>).FullName}."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithNonLetterCharacterName( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains one or more options whose names don't start with a letter character: -{invalidOptions.JoinToString(Environment.NewLine)} - -Option names must start with a letter character (i.e. not a digit and not a special character)."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException OptionsWithNonLetterCharacterShortName( - CommandSchema command, - IReadOnlyList invalidOptions) - { - var message = $@" -Command '{command.Type.FullName}' is invalid because it contains one or more options whose short names are not letter characters: -{invalidOptions.JoinToString(Environment.NewLine)} - -Option short names must be letter characters (i.e. not digits and not special characters)."; - - return new CliFxException(message.Trim()); + ActualMessage = message; + ExitCode = exitCode; + ShowHelp = showHelp; } } - // End-user-facing exceptions - // Avoid internal details and fix recommendations here public partial class CliFxException { - internal static CliFxException CannotConvertMultipleValuesToNonScalar( - CommandParameterSchema parameter, - IReadOnlyList values) - { - var message = $@" -Parameter {parameter.GetUserFacingDisplayString()} expects a single value, but provided with multiple: -{values.Select(v => v.Quote()).JoinToString(" ")}"; + // Internal errors don't show help because they're meant for the developer + // and not the end-user of the application. + internal static CliFxException InternalError(string message, Exception? innerException = null) => + new(message, DefaultExitCode, false, innerException); - return new CliFxException(message.Trim()); - } - - internal static CliFxException CannotConvertMultipleValuesToNonScalar( - CommandOptionSchema option, - IReadOnlyList values) - { - var message = $@" -Option {option.GetUserFacingDisplayString()} expects a single value, but provided with multiple: -{values.Select(v => v.Quote()).JoinToString(" ")}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException CannotConvertMultipleValuesToNonScalar( - CommandArgumentSchema argument, - IReadOnlyList values) => argument switch - { - CommandParameterSchema parameter => CannotConvertMultipleValuesToNonScalar(parameter, values), - CommandOptionSchema option => CannotConvertMultipleValuesToNonScalar(option, values), - _ => throw new ArgumentOutOfRangeException(nameof(argument)) - }; - - internal static CliFxException CannotConvertToType( - CommandParameterSchema parameter, - string? value, - Type type, - Exception? innerException = null) - { - var message = $@" -Can't convert value ""{value ?? ""}"" to type '{type.Name}' for parameter {parameter.GetUserFacingDisplayString()}. -{innerException?.Message ?? "This type is not supported."}"; - - return new CliFxException(message.Trim(), innerException); - } - - internal static CliFxException CannotConvertToType( - CommandOptionSchema option, - string? value, - Type type, - Exception? innerException = null) - { - var message = $@" -Can't convert value ""{value ?? ""}"" to type '{type.Name}' for option {option.GetUserFacingDisplayString()}. -{innerException?.Message ?? "This type is not supported."}"; - - return new CliFxException(message.Trim(), innerException); - } - - internal static CliFxException CannotConvertToType( - CommandArgumentSchema argument, - string? value, - Type type, - Exception? innerException = null) => argument switch - { - CommandParameterSchema parameter => CannotConvertToType(parameter, value, type, innerException), - CommandOptionSchema option => CannotConvertToType(option, value, type, innerException), - _ => throw new ArgumentOutOfRangeException(nameof(argument)) - }; - - internal static CliFxException CannotConvertNonScalar( - CommandParameterSchema parameter, - IReadOnlyList values, - Type type) - { - var message = $@" -Can't convert provided values to type '{type.Name}' for parameter {parameter.GetUserFacingDisplayString()}: -{values.Select(v => v.Quote()).JoinToString(" ")} - -Target type is not assignable from array and doesn't have a public constructor that takes an array."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException CannotConvertNonScalar( - CommandOptionSchema option, - IReadOnlyList values, - Type type) - { - var message = $@" -Can't convert provided values to type '{type.Name}' for option {option.GetUserFacingDisplayString()}: -{values.Select(v => v.Quote()).JoinToString(" ")} - -Target type is not assignable from array and doesn't have a public constructor that takes an array."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException CannotConvertNonScalar( - CommandArgumentSchema argument, - IReadOnlyList values, - Type type) => argument switch - { - CommandParameterSchema parameter => CannotConvertNonScalar(parameter, values, type), - CommandOptionSchema option => CannotConvertNonScalar(option, values, type), - _ => throw new ArgumentOutOfRangeException(nameof(argument)) - }; - - internal static CliFxException ParameterNotSet(CommandParameterSchema parameter) - { - var message = $@" -Missing value for parameter {parameter.GetUserFacingDisplayString()}."; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException RequiredOptionsNotSet(IReadOnlyList options) - { - var message = $@" -Missing values for one or more required options: -{options.Select(o => o.GetUserFacingDisplayString()).JoinToString(Environment.NewLine)}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException UnrecognizedParametersProvided( - IReadOnlyList parameterInputs) - { - var message = $@" -Unrecognized parameters provided: -{parameterInputs.Select(p => p.Value).JoinToString(Environment.NewLine)}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException UnrecognizedOptionsProvided(IReadOnlyList optionInputs) - { - var message = $@" -Unrecognized options provided: -{optionInputs.Select(o => o.GetRawAlias()).JoinToString(Environment.NewLine)}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ValidationFailed( - CommandParameterSchema parameter, - IReadOnlyList failedResults) - { - var message = $@" -Value provided for parameter {parameter.GetUserFacingDisplayString()}: -{failedResults.Select(r => r.ErrorMessage).JoinToString(Environment.NewLine)}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ValidationFailed( - CommandOptionSchema option, - IReadOnlyList failedResults) - { - var message = $@" -Value provided for option {option.GetUserFacingDisplayString()}: -{failedResults.Select(r => r.ErrorMessage).JoinToString(Environment.NewLine)}"; - - return new CliFxException(message.Trim()); - } - - internal static CliFxException ValidationFailed( - CommandArgumentSchema argument, - IReadOnlyList failedResults) => argument switch - { - CommandParameterSchema parameter => ValidationFailed(parameter, failedResults), - CommandOptionSchema option => ValidationFailed(option, failedResults), - _ => throw new ArgumentOutOfRangeException(nameof(argument)) - }; + // User errors are typically caused by invalid input and they're meant for + // the end-user, so we want to show help. + internal static CliFxException UserError(string message, Exception? innerException = null) => + new(message, DefaultExitCode, true, innerException); } } \ No newline at end of file diff --git a/CliFx/Exceptions/CommandException.cs b/CliFx/Exceptions/CommandException.cs index 029a3f9..3afe81e 100644 --- a/CliFx/Exceptions/CommandException.cs +++ b/CliFx/Exceptions/CommandException.cs @@ -3,67 +3,21 @@ namespace CliFx.Exceptions { /// - /// Thrown when a command cannot proceed with normal execution due to an error. - /// Use this exception if you want to report an error that occured during the execution of a command. - /// This exception also allows specifying exit code which will be returned to the calling process. + /// Exception thrown when a command cannot proceed with its normal execution due to an error. + /// Use this exception to report an error to the console and return a specific exit code. /// - public class CommandException : Exception + public class CommandException : CliFxException { - private const int DefaultExitCode = 1; - - private readonly bool _isMessageSet; - - /// - /// Exit code returned by the application when this exception is handled. - /// - public int ExitCode { get; } - - /// - /// Whether to show the help text after handling this exception. - /// - public bool ShowHelp { get; } - /// /// Initializes an instance of . /// - /// - /// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow. - /// - public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false) - : base(message, innerException) - { - ExitCode = exitCode; - ShowHelp = showHelp; - - // Message property has a fallback so it's never empty, hence why we need this check - _isMessageSet = !string.IsNullOrWhiteSpace(message); - } - - /// - /// Initializes an instance of . - /// - /// - /// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow. - /// - public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false) - : this(message, null, exitCode, showHelp) + public CommandException( + string message, + int exitCode = DefaultExitCode, + bool showHelp = false, + Exception? innerException = null) + : base(message, exitCode, showHelp, innerException) { } - - /// - /// Initializes an instance of . - /// - /// - /// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow. - /// - public CommandException(int exitCode = DefaultExitCode, bool showHelp = false) - : this(null, exitCode, showHelp) - { - } - - /// - public override string ToString() => _isMessageSet - ? Message - : base.ToString(); } } \ No newline at end of file diff --git a/CliFx/Extensibility/BindingConverter.cs b/CliFx/Extensibility/BindingConverter.cs new file mode 100644 index 0000000..85b73b3 --- /dev/null +++ b/CliFx/Extensibility/BindingConverter.cs @@ -0,0 +1,21 @@ +namespace CliFx.Extensibility +{ + // Used internally to simplify usage from reflection + internal interface IBindingConverter + { + object? Convert(string? rawValue); + } + + /// + /// Base type for custom converters. + /// + public abstract class BindingConverter : IBindingConverter + { + /// + /// Parses value from a raw command line argument. + /// + public abstract T Convert(string? rawValue); + + object? IBindingConverter.Convert(string? rawValue) => Convert(rawValue); + } +} \ No newline at end of file diff --git a/CliFx/Extensibility/BindingValidationError.cs b/CliFx/Extensibility/BindingValidationError.cs new file mode 100644 index 0000000..c37e0eb --- /dev/null +++ b/CliFx/Extensibility/BindingValidationError.cs @@ -0,0 +1,18 @@ +namespace CliFx.Extensibility +{ + /// + /// Represents a validation error. + /// + public class BindingValidationError + { + /// + /// Error message shown to the user. + /// + public string Message { get; } + + /// + /// Initializes an instance of . + /// + public BindingValidationError(string message) => Message = message; + } +} \ No newline at end of file diff --git a/CliFx/Extensibility/BindingValidator.cs b/CliFx/Extensibility/BindingValidator.cs new file mode 100644 index 0000000..023666d --- /dev/null +++ b/CliFx/Extensibility/BindingValidator.cs @@ -0,0 +1,36 @@ +namespace CliFx.Extensibility +{ + // Used internally to simplify usage from reflection + internal interface IBindingValidator + { + BindingValidationError? Validate(object? value); + } + + /// + /// Base type for custom validators. + /// + public abstract class BindingValidator : IBindingValidator + { + /// + /// Returns a successful validation result. + /// + protected BindingValidationError? Ok() => null; + + /// + /// Returns a non-successful validation result. + /// + protected BindingValidationError Error(string message) => new(message); + + /// + /// Validates the value bound to a parameter or an option. + /// Returns null if validation is successful, or an error in case of failure. + /// + /// + /// You can use the utility methods and to + /// create an appropriate result. + /// + public abstract BindingValidationError? Validate(T value); + + BindingValidationError? IBindingValidator.Validate(object? value) => Validate((T) value!); + } +} diff --git a/CliFx/FallbackDefaultCommand.cs b/CliFx/FallbackDefaultCommand.cs new file mode 100644 index 0000000..e0ffa4b --- /dev/null +++ b/CliFx/FallbackDefaultCommand.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Infrastructure; +using CliFx.Schema; + +namespace CliFx +{ + // Fallback command used when the application doesn't have one configured. + // This command is only used as a stub for help text. + [Command] + internal class FallbackDefaultCommand : ICommand + { + public static CommandSchema Schema { get; } = + CommandSchema.Resolve(typeof(FallbackDefaultCommand)); + + // Never actually executed + [ExcludeFromCodeCoverage] + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx/Formatting/CommandInputConsoleFormatter.cs b/CliFx/Formatting/CommandInputConsoleFormatter.cs new file mode 100644 index 0000000..00d6291 --- /dev/null +++ b/CliFx/Formatting/CommandInputConsoleFormatter.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using CliFx.Infrastructure; +using CliFx.Input; + +namespace CliFx.Formatting +{ + internal class CommandInputConsoleFormatter : ConsoleFormatter + { + public CommandInputConsoleFormatter(ConsoleWriter consoleWriter) + : base(consoleWriter) + { + } + + private void WriteCommandLineArguments(CommandInput commandInput) + { + Write("Command line:"); + WriteLine(); + + WriteHorizontalMargin(); + + // Command name + if (!string.IsNullOrWhiteSpace(commandInput.CommandName)) + { + Write(ConsoleColor.Cyan, commandInput.CommandName); + Write(' '); + } + + // Parameters + foreach (var parameterInput in commandInput.Parameters) + { + Write('<'); + Write(ConsoleColor.White, parameterInput.Value); + Write('>'); + Write(' '); + } + + // Options + foreach (var optionInput in commandInput.Options) + { + Write('['); + + // Identifier + Write(ConsoleColor.White, optionInput.GetFormattedIdentifier()); + + // Value(s) + foreach (var value in optionInput.Values) + { + Write(' '); + Write(ConsoleColor.DarkGray, '"'); + Write(value); + Write(ConsoleColor.DarkGray, '"'); + } + + Write(']'); + Write(' '); + } + + WriteLine(); + } + + private void WriteEnvironmentVariables(CommandInput commandInput) + { + Write("Environment:"); + WriteLine(); + + // Environment variables + foreach (var environmentVariableInput in commandInput.EnvironmentVariables) + { + WriteHorizontalMargin(); + + // Name + Write(ConsoleColor.White, environmentVariableInput.Name); + + Write('='); + + // Value + Write(ConsoleColor.DarkGray, '"'); + Write(environmentVariableInput.Value); + Write(ConsoleColor.DarkGray, '"'); + + WriteLine(); + } + } + + public void WriteCommandInput(CommandInput commandInput) + { + WriteCommandLineArguments(commandInput); + WriteLine(); + WriteEnvironmentVariables(commandInput); + } + } + + internal static class CommandInputConsoleFormatterExtensions + { + public static void WriteCommandInput(this IConsole console, CommandInput commandInput) => + new CommandInputConsoleFormatter(console.Output).WriteCommandInput(commandInput); + } +} \ No newline at end of file diff --git a/CliFx/Formatting/ConsoleFormatter.cs b/CliFx/Formatting/ConsoleFormatter.cs new file mode 100644 index 0000000..6f41f44 --- /dev/null +++ b/CliFx/Formatting/ConsoleFormatter.cs @@ -0,0 +1,75 @@ +using System; +using CliFx.Infrastructure; + +namespace CliFx.Formatting +{ + internal class ConsoleFormatter + { + private readonly ConsoleWriter _consoleWriter; + + private int _column; + private int _row; + + public bool IsEmpty => _column == 0 && _row == 0; + + public ConsoleFormatter(ConsoleWriter consoleWriter) => + _consoleWriter = consoleWriter; + + public void Write(string? value) + { + _consoleWriter.Write(value); + _column += value?.Length ?? 0; + } + + public void Write(char value) + { + _consoleWriter.Write(value); + _column++; + } + + public void Write(ConsoleColor foregroundColor, string? value) + { + using (_consoleWriter.Console.WithForegroundColor(foregroundColor)) + Write(value); + } + + public void Write(ConsoleColor foregroundColor, char value) + { + using (_consoleWriter.Console.WithForegroundColor(foregroundColor)) + Write(value); + } + + public void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string? value) + { + using (_consoleWriter.Console.WithColors(foregroundColor, backgroundColor)) + Write(value); + } + + public void WriteLine() + { + _consoleWriter.WriteLine(); + _column = 0; + _row++; + } + + public void WriteVerticalMargin(int size = 1) + { + for (var i = 0; i < size; i++) + WriteLine(); + } + + public void WriteHorizontalMargin(int size = 2) + { + for (var i = 0; i < size; i++) + Write(' '); + } + + public void WriteColumnMargin(int columnSize = 20, int offsetSize = 2) + { + if (_column + offsetSize < columnSize) + WriteHorizontalMargin(columnSize - _column); + else + WriteHorizontalMargin(offsetSize); + } + } +} \ No newline at end of file diff --git a/CliFx/Formatting/ExceptionConsoleFormatter.cs b/CliFx/Formatting/ExceptionConsoleFormatter.cs new file mode 100644 index 0000000..feda0a3 --- /dev/null +++ b/CliFx/Formatting/ExceptionConsoleFormatter.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using CliFx.Utils; + +namespace CliFx.Formatting +{ + internal class ExceptionConsoleFormatter : ConsoleFormatter + { + public ExceptionConsoleFormatter(ConsoleWriter consoleWriter) + : base(consoleWriter) + { + } + + private void WriteStackFrame(StackFrame stackFrame, int indentLevel) + { + WriteHorizontalMargin(2 + 4 * indentLevel); + Write("at "); + + // Fully qualified method name + Write(ConsoleColor.DarkGray, stackFrame.ParentType + '.'); + Write(ConsoleColor.Yellow, stackFrame.MethodName); + + // Method parameters + + Write('('); + + for (var i = 0; i < stackFrame.Parameters.Count; i++) + { + var parameter = stackFrame.Parameters[i]; + + // Separator + if (i > 0) + { + Write(", "); + } + + // Parameter type + Write(ConsoleColor.Blue, parameter.Type); + + // Parameter name (can be null for dynamically generated methods) + if (!string.IsNullOrWhiteSpace(parameter.Name)) + { + Write(' '); + Write(ConsoleColor.White, parameter.Name); + } + } + + Write(") "); + + // Location + if (!string.IsNullOrWhiteSpace(stackFrame.FilePath)) + { + var stackFrameDirectoryPath = + Path.GetDirectoryName(stackFrame.FilePath) + Path.DirectorySeparatorChar; + + var stackFrameFileName = Path.GetFileName(stackFrame.FilePath); + + Write("in "); + + // File path + Write(ConsoleColor.DarkGray, stackFrameDirectoryPath); + Write(ConsoleColor.Yellow, stackFrameFileName); + + // Source position + if (!string.IsNullOrWhiteSpace(stackFrame.LineNumber)) + { + Write(':'); + Write(ConsoleColor.Blue, stackFrame.LineNumber); + } + } + + WriteLine(); + } + + private void WriteException(Exception exception, int indentLevel) + { + WriteHorizontalMargin(4 * indentLevel); + + // Fully qualified exception type + var exceptionType = exception.GetType(); + Write(ConsoleColor.DarkGray, exceptionType.Namespace + '.'); + Write(ConsoleColor.White, exceptionType.Name); + Write(": "); + + // Exception message + Write(ConsoleColor.Red, exception.Message); + WriteLine(); + + // Recurse into inner exceptions + if (exception.InnerException is not null) + { + WriteException(exception.InnerException, indentLevel + 1); + } + + // Non-thrown exceptions (e.g. inner exceptions) have no stacktrace + if (!string.IsNullOrWhiteSpace(exception.StackTrace)) + { + // Parse and pretty-print the stacktrace + foreach (var stackFrame in StackFrame.ParseMany(exception.StackTrace)) + { + WriteStackFrame(stackFrame, indentLevel); + } + } + } + + public void WriteException(Exception exception) + { + // Domain exceptions should be printed with minimal information + // because they are meant for the user of the application, + // not the user of the library. + if (exception is CliFxException cliFxException && + !string.IsNullOrWhiteSpace(cliFxException.ActualMessage)) + { + Write(ConsoleColor.Red, cliFxException.ActualMessage); + WriteLine(); + } + // All other exceptions most likely indicate an actual bug + // and should include stacktrace and other detailed information. + else + { + Write(ConsoleColor.White, ConsoleColor.DarkRed, "ERROR"); + WriteLine(); + WriteException(exception, 0); + } + } + } + + internal static class ExceptionConsoleFormatterExtensions + { + public static void WriteException(this IConsole console, Exception exception) => + new ExceptionConsoleFormatter(console.Error).WriteException(exception); + } +} \ No newline at end of file diff --git a/CliFx/Formatting/HelpConsoleFormatter.cs b/CliFx/Formatting/HelpConsoleFormatter.cs new file mode 100644 index 0000000..1236d18 --- /dev/null +++ b/CliFx/Formatting/HelpConsoleFormatter.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using CliFx.Infrastructure; +using CliFx.Schema; +using CliFx.Utils.Extensions; + +namespace CliFx.Formatting +{ + internal class HelpConsoleFormatter : ConsoleFormatter + { + private readonly HelpContext _context; + + public HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext context) + : base(consoleWriter) + { + _context = context; + } + + private void WriteHeader(string text) + { + Write(ConsoleColor.White, text.ToUpperInvariant()); + WriteLine(); + } + + private void WriteCommandInvocation() + { + Write(ConsoleColor.DarkGray, _context.ApplicationMetadata.ExecutableName); + + // Command name + if (!string.IsNullOrWhiteSpace(_context.CommandSchema.Name)) + { + Write(' '); + Write(ConsoleColor.Cyan, _context.CommandSchema.Name); + } + } + + private void WriteApplicationInfo() + { + if (!IsEmpty) + WriteVerticalMargin(); + + // Title and version + Write(ConsoleColor.White, _context.ApplicationMetadata.Title); + Write(' '); + Write(ConsoleColor.Yellow, _context.ApplicationMetadata.Version); + WriteLine(); + + // Description + if (!string.IsNullOrWhiteSpace(_context.ApplicationMetadata.Description)) + { + WriteHorizontalMargin(); + Write(_context.ApplicationMetadata.Description); + WriteLine(); + } + } + + private void WriteCommandUsage() + { + if (!IsEmpty) + WriteVerticalMargin(); + + WriteHeader("Usage"); + + // Current command usage + { + WriteHorizontalMargin(); + + WriteCommandInvocation(); + Write(' '); + + // Parameters + foreach (var parameter in _context.CommandSchema.Parameters) + { + Write(ConsoleColor.DarkCyan, parameter.Property.IsScalar() + ? $"<{parameter.Name}>" + : $"<{parameter.Name}...>" + ); + Write(' '); + } + + // Required options + foreach (var option in _context.CommandSchema.Options.Where(o => o.IsRequired)) + { + Write(ConsoleColor.Yellow, !string.IsNullOrWhiteSpace(option.Name) + ? $"--{option.Name}" + : $"-{option.ShortName}" + ); + Write(' '); + + Write(ConsoleColor.White, option.Property.IsScalar() + ? "" + : "" + ); + Write(' '); + } + + // Placeholder for non-required options + if (_context.CommandSchema.Options.Any(o => !o.IsRequired)) + { + Write(ConsoleColor.Yellow, "[options]"); + } + + WriteLine(); + } + + // Child command usage + var childCommandSchemas = _context + .ApplicationSchema + .GetChildCommands(_context.CommandSchema.Name); + + if (childCommandSchemas.Any()) + { + WriteHorizontalMargin(); + + WriteCommandInvocation(); + Write(' '); + + // Placeholder for child command + Write(ConsoleColor.Cyan, "[command]"); + Write(' '); + + // Placeholder for other arguments + Write("[...]"); + + WriteLine(); + } + } + + private void WriteCommandDescription() + { + if (string.IsNullOrWhiteSpace(_context.CommandSchema.Description)) + return; + + if (!IsEmpty) + WriteVerticalMargin(); + + WriteHeader("Description"); + + WriteHorizontalMargin(); + + Write(_context.CommandSchema.Description); + WriteLine(); + } + + private void WriteCommandParameters() + { + if (!_context.CommandSchema.Parameters.Any()) + return; + + if (!IsEmpty) + WriteVerticalMargin(); + + WriteHeader("Parameters"); + + foreach (var parameterSchema in _context.CommandSchema.Parameters.OrderBy(p => p.Order)) + { + Write(ConsoleColor.Red, "* "); + Write(ConsoleColor.DarkCyan, $"{parameterSchema.Name}"); + + WriteColumnMargin(); + + // Description + if (!string.IsNullOrWhiteSpace(parameterSchema.Description)) + { + Write(parameterSchema.Description); + Write(' '); + } + + // Valid values + var validValues = parameterSchema.Property.GetValidValues(); + if (validValues.Any()) + { + Write(ConsoleColor.White, "Choices: "); + + var isFirst = true; + foreach (var validValue in validValues) + { + if (validValue is null) + continue; + + if (isFirst) + { + isFirst = false; + } + else + { + Write(", "); + } + + Write(ConsoleColor.DarkGray, '"'); + Write(ConsoleColor.White, validValue.ToString()); + Write(ConsoleColor.DarkGray, '"'); + } + + Write('.'); + Write(' '); + } + + WriteLine(); + } + } + + private void WriteCommandOptions() + { + if (!IsEmpty) + WriteVerticalMargin(); + + WriteHeader("Options"); + + foreach (var optionSchema in _context.CommandSchema.Options.OrderByDescending(o => o.IsRequired)) + { + if (optionSchema.IsRequired) + { + Write(ConsoleColor.Red, "* "); + } + else + { + WriteHorizontalMargin(); + } + + // Short name + if (optionSchema.ShortName is not null) + { + Write(ConsoleColor.Yellow, $"-{optionSchema.ShortName}"); + } + + // Separator + if (!string.IsNullOrWhiteSpace(optionSchema.Name) && optionSchema.ShortName is not null) + { + Write('|'); + } + + // Name + if (!string.IsNullOrWhiteSpace(optionSchema.Name)) + { + Write(ConsoleColor.Yellow, $"--{optionSchema.Name}"); + } + + WriteColumnMargin(); + + // Description + if (!string.IsNullOrWhiteSpace(optionSchema.Description)) + { + Write(optionSchema.Description); + Write(' '); + } + + // Valid values + var validValues = optionSchema.Property.GetValidValues(); + if (validValues.Any()) + { + Write(ConsoleColor.White, "Choices: "); + + var isFirst = true; + foreach (var validValue in validValues) + { + if (validValue is null) + continue; + + if (isFirst) + { + isFirst = false; + } + else + { + Write(", "); + } + + Write(ConsoleColor.DarkGray, '"'); + Write(ConsoleColor.White, validValue.ToString()); + Write(ConsoleColor.DarkGray, '"'); + } + + Write('.'); + Write(' '); + } + + // Environment variable + if (!string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariable)) + { + Write(ConsoleColor.White, "Environment variable: "); + Write(optionSchema.EnvironmentVariable); + Write('.'); + Write(' '); + } + + // Default value + if (!optionSchema.IsRequired) + { + var defaultValue = _context.CommandDefaultValues.GetValueOrDefault(optionSchema); + if (defaultValue is not null) + { + // Non-Scalar + if (defaultValue is not string && defaultValue is IEnumerable defaultValues) + { + var elementType = + defaultValues.GetType().TryGetEnumerableUnderlyingType() ?? + typeof(object); + + if (elementType.IsToStringOverriden()) + { + Write(ConsoleColor.White, "Default: "); + + var isFirst = true; + + foreach (var element in defaultValues) + { + if (isFirst) + { + isFirst = false; + } + else + { + Write(", "); + } + + Write(ConsoleColor.DarkGray, '"'); + Write(element.ToString(CultureInfo.InvariantCulture)); + Write(ConsoleColor.DarkGray, '"'); + } + } + } + else + { + if (defaultValue.GetType().IsToStringOverriden()) + { + Write(ConsoleColor.White, "Default: "); + + Write(ConsoleColor.DarkGray, '"'); + Write(defaultValue.ToString(CultureInfo.InvariantCulture)); + Write(ConsoleColor.DarkGray, '"'); + } + } + + Write('.'); + } + } + + WriteLine(); + } + } + + private void WriteCommandChildren() + { + var childCommandSchemas = _context + .ApplicationSchema + .GetChildCommands(_context.CommandSchema.Name); + + if (!childCommandSchemas.Any()) + return; + + if (!IsEmpty) + WriteVerticalMargin(); + + WriteHeader("Commands"); + + foreach (var childCommandSchema in childCommandSchemas) + { + // Name + WriteHorizontalMargin(); + Write( + ConsoleColor.Cyan, + // Relative to current command + childCommandSchema + .Name? + .Substring(_context.CommandSchema.Name?.Length ?? 0) + .Trim() + ); + + WriteColumnMargin(); + + // Description + if (!string.IsNullOrWhiteSpace(childCommandSchema.Description)) + { + Write(childCommandSchema.Description); + Write(' '); + } + + // Child commands of child command + var grandChildCommandSchemas = _context + .ApplicationSchema + .GetChildCommands(childCommandSchema.Name); + + if (grandChildCommandSchemas.Any()) + { + Write(ConsoleColor.White, "Subcommands: "); + + var isFirst = true; + + foreach (var grandChildCommandSchema in grandChildCommandSchemas) + { + if (isFirst) + { + isFirst = false; + } + else + { + Write(", "); + } + + Write( + ConsoleColor.Cyan, + // Relative to current command (not the parent) + grandChildCommandSchema + .Name? + .Substring(_context.CommandSchema.Name?.Length ?? 0) + .Trim() + ); + } + + Write('.'); + } + + WriteLine(); + } + + // Child command help tip + WriteVerticalMargin(); + Write("You can run `"); + WriteCommandInvocation(); + Write(' '); + Write(ConsoleColor.Cyan, "[command]"); + Write(' '); + Write(ConsoleColor.White, "--help"); + Write("` to show help on a specific command."); + + WriteLine(); + } + + public void WriteHelpText() + { + WriteApplicationInfo(); + WriteCommandUsage(); + WriteCommandDescription(); + WriteCommandParameters(); + WriteCommandOptions(); + WriteCommandChildren(); + } + } + + internal static class HelpConsoleFormatterExtensions + { + public static void WriteHelpText(this IConsole console, HelpContext context) => + new HelpConsoleFormatter(console.Output, context).WriteHelpText(); + } +} \ No newline at end of file diff --git a/CliFx/Formatting/HelpContext.cs b/CliFx/Formatting/HelpContext.cs new file mode 100644 index 0000000..ac4c125 --- /dev/null +++ b/CliFx/Formatting/HelpContext.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using CliFx.Schema; + +namespace CliFx.Formatting +{ + internal class HelpContext + { + public ApplicationMetadata ApplicationMetadata { get; } + + public ApplicationSchema ApplicationSchema { get; } + + public CommandSchema CommandSchema { get; } + + public IReadOnlyDictionary CommandDefaultValues { get; } + + public HelpContext( + ApplicationMetadata applicationMetadata, + ApplicationSchema applicationSchema, + CommandSchema commandSchema, + IReadOnlyDictionary commandDefaultValues) + { + ApplicationMetadata = applicationMetadata; + ApplicationSchema = applicationSchema; + CommandSchema = commandSchema; + CommandDefaultValues = commandDefaultValues; + } + } +} \ No newline at end of file diff --git a/CliFx/ICommand.cs b/CliFx/ICommand.cs index cc7ff6b..51d2eaa 100644 --- a/CliFx/ICommand.cs +++ b/CliFx/ICommand.cs @@ -1,17 +1,20 @@ using System.Threading.Tasks; +using CliFx.Infrastructure; namespace CliFx { /// - /// Entry point in a command line application. + /// Entry point through which the user interacts with the command line application. /// public interface ICommand { /// /// Executes the command using the specified implementation of . - /// This is the method that's called when the command is invoked by a user through command line. /// - /// If the execution of the command is not asynchronous, simply end the method with return default; + /// + /// If the execution of the command is not asynchronous, simply end the method with + /// return default; + /// ValueTask ExecuteAsync(IConsole console); } } \ No newline at end of file diff --git a/CliFx/IConsole.cs b/CliFx/IConsole.cs deleted file mode 100644 index 72a2153..0000000 --- a/CliFx/IConsole.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using CliFx.Internal; - -namespace CliFx -{ - /// - /// Abstraction for interacting with the console. - /// - public interface IConsole - { - /// - /// Input stream (stdin). - /// - StreamReader Input { get; } - - /// - /// Whether the input stream is redirected. - /// - bool IsInputRedirected { get; } - - /// - /// Output stream (stdout). - /// - StreamWriter Output { get; } - - /// - /// Whether the output stream is redirected. - /// - bool IsOutputRedirected { get; } - - /// - /// Error stream (stderr). - /// - StreamWriter Error { get; } - - /// - /// Whether the error stream is redirected. - /// - bool IsErrorRedirected { get; } - - /// - /// Current foreground color. - /// - ConsoleColor ForegroundColor { get; set; } - - /// - /// Current background color. - /// - ConsoleColor BackgroundColor { get; set; } - - /// - /// Resets foreground and background color to default values. - /// - void ResetColor(); - - /// - /// Cursor left offset. - /// - int CursorLeft { get; set; } - - /// - /// Cursor top offset. - /// - int CursorTop { get; set; } - - /// - /// Defers the application termination in case of a cancellation request and returns the token that represents it. - /// Subsequent calls to this method return the same token. - /// - /// - /// When working with :
- /// - Cancellation can be requested by the user by pressing Ctrl+C.
- /// - Cancellation can only be deferred once, subsequent requests to cancel by the user will result in instant termination.
- /// - Any code executing prior to calling this method is not cancellation-aware and as such will terminate instantly when cancellation is requested. - ///
- CancellationToken GetCancellationToken(); - } - - /// - /// Extensions for . - /// - public static class ConsoleExtensions - { - /// - /// Sets console foreground color, executes specified action, and sets the color back to the original value. - /// - public static void WithForegroundColor( - this IConsole console, - ConsoleColor foregroundColor, - Action action) - { - var lastColor = console.ForegroundColor; - console.ForegroundColor = foregroundColor; - - action(); - - console.ForegroundColor = lastColor; - } - - /// - /// Sets console background color, executes specified action, and sets the color back to the original value. - /// - public static void WithBackgroundColor( - this IConsole console, - ConsoleColor backgroundColor, - Action action) - { - var lastColor = console.BackgroundColor; - console.BackgroundColor = backgroundColor; - - action(); - - console.BackgroundColor = lastColor; - } - - /// - /// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values. - /// - public static void WithColors( - this IConsole console, - ConsoleColor foregroundColor, - ConsoleColor backgroundColor, - Action action) => - console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action)); - - private static void WriteException( - this IConsole console, - Exception exception, - int indentLevel) - { - var exceptionType = exception.GetType(); - - var indentationShared = new string(' ', 4 * indentLevel); - var indentationLocal = new string(' ', 2); - - // Fully qualified exception type - console.Error.Write(indentationShared); - console.WithForegroundColor(ConsoleColor.DarkGray, () => - console.Error.Write(exceptionType.Namespace + ".") - ); - console.WithForegroundColor(ConsoleColor.White, () => - console.Error.Write(exceptionType.Name) - ); - console.Error.Write(": "); - - // Exception message - console.WithForegroundColor(ConsoleColor.Red, () => console.Error.WriteLine(exception.Message)); - - // Recurse into inner exceptions - if (exception.InnerException != null) - { - console.WriteException(exception.InnerException, indentLevel + 1); - } - - // Try to parse and pretty-print the stack trace - try - { - foreach (var stackFrame in StackFrame.ParseMany(exception.StackTrace)) - { - console.Error.Write(indentationShared + indentationLocal); - console.Error.Write("at "); - - // "CliFx.Demo.Commands.BookAddCommand." - console.WithForegroundColor(ConsoleColor.DarkGray, () => - console.Error.Write(stackFrame.ParentType + ".") - ); - - // "ExecuteAsync" - console.WithForegroundColor(ConsoleColor.Yellow, () => - console.Error.Write(stackFrame.MethodName) - ); - - console.Error.Write("("); - - for (var i = 0; i < stackFrame.Parameters.Count; i++) - { - var parameter = stackFrame.Parameters[i]; - - // Separator - if (i > 0) - { - console.Error.Write(", "); - } - - // "IConsole" - console.WithForegroundColor(ConsoleColor.Blue, () => - console.Error.Write(parameter.Type) - ); - - if (!string.IsNullOrWhiteSpace(parameter.Name)) - { - console.Error.Write(" "); - - // "console" - console.WithForegroundColor(ConsoleColor.White, () => - console.Error.Write(parameter.Name) - ); - } - } - - console.Error.Write(") "); - - // Location - if (!string.IsNullOrWhiteSpace(stackFrame.FilePath)) - { - console.Error.Write("in"); - console.Error.Write("\n" + indentationShared + indentationLocal + indentationLocal); - - // "E:\Projects\Softdev\CliFx\CliFx.Demo\Commands\" - var stackFrameDirectoryPath = Path.GetDirectoryName(stackFrame.FilePath); - console.WithForegroundColor(ConsoleColor.DarkGray, () => - console.Error.Write(stackFrameDirectoryPath + Path.DirectorySeparatorChar) - ); - - // "BookAddCommand.cs" - var stackFrameFileName = Path.GetFileName(stackFrame.FilePath); - console.WithForegroundColor(ConsoleColor.Yellow, () => - console.Error.Write(stackFrameFileName) - ); - - if (!string.IsNullOrWhiteSpace(stackFrame.LineNumber)) - { - console.Error.Write(":"); - - // "35" - console.WithForegroundColor(ConsoleColor.Blue, () => - console.Error.Write(stackFrame.LineNumber) - ); - } - } - - console.Error.WriteLine(); - } - } - // If any point of parsing has failed - print the stack trace without any formatting - catch - { - console.Error.WriteLine(exception.StackTrace); - } - } - - //Should this be public? - internal static void WriteException( - this IConsole console, - Exception exception) => - console.WriteException(exception, 0); - } -} \ No newline at end of file diff --git a/CliFx/Infrastructure/ConsoleReader.cs b/CliFx/Infrastructure/ConsoleReader.cs new file mode 100644 index 0000000..c94570b --- /dev/null +++ b/CliFx/Infrastructure/ConsoleReader.cs @@ -0,0 +1,39 @@ +using System.IO; +using System.Text; + +namespace CliFx.Infrastructure +{ + /// + /// Implements a for reading characters from a console stream. + /// + public partial class ConsoleReader : StreamReader + { + /// + /// Console that owns this stream. + /// + public IConsole Console { get; } + + /// + /// Initializes an instance of . + /// + public ConsoleReader(IConsole console, Stream stream, Encoding encoding) + : base(stream, encoding, false) + { + Console = console; + } + + /// + /// Initializes an instance of . + /// + public ConsoleReader(IConsole console, Stream stream) + : this(console, stream, System.Console.InputEncoding) + { + } + } + + public partial class ConsoleReader + { + internal static ConsoleReader Create(IConsole console, Stream? stream) => + new(console, stream is not null ? Stream.Synchronized(stream) : Stream.Null); + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/ConsoleWriter.cs b/CliFx/Infrastructure/ConsoleWriter.cs new file mode 100644 index 0000000..f4dd288 --- /dev/null +++ b/CliFx/Infrastructure/ConsoleWriter.cs @@ -0,0 +1,39 @@ +using System.IO; +using System.Text; + +namespace CliFx.Infrastructure +{ + /// + /// Implements a for writing characters to a console stream. + /// + public partial class ConsoleWriter : StreamWriter + { + /// + /// Console that owns this stream. + /// + public IConsole Console { get; } + + /// + /// Initializes an instance of . + /// + public ConsoleWriter(IConsole console, Stream stream, Encoding encoding) + : base(stream, encoding) + { + Console = console; + } + + /// + /// Initializes an instance of . + /// + public ConsoleWriter(IConsole console, Stream stream) + : this(console, stream, System.Console.OutputEncoding) + { + } + } + + public partial class ConsoleWriter + { + internal static ConsoleWriter Create(IConsole console, Stream? stream) => + new(console, stream is not null ? Stream.Synchronized(stream) : Stream.Null) {AutoFlush = true}; + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/DefaultTypeActivator.cs b/CliFx/Infrastructure/DefaultTypeActivator.cs new file mode 100644 index 0000000..c2a752d --- /dev/null +++ b/CliFx/Infrastructure/DefaultTypeActivator.cs @@ -0,0 +1,32 @@ +using System; +using CliFx.Exceptions; + +namespace CliFx.Infrastructure +{ + /// + /// Implementation of that instantiates an object + /// by using its parameterless constructor. + /// + public class DefaultTypeActivator : ITypeActivator + { + /// + public object CreateInstance(Type type) + { + try + { + return Activator.CreateInstance(type); + } + catch (Exception ex) + { + throw CliFxException.InternalError( + $"Failed to create an instance of type `{type.FullName}`." + + Environment.NewLine + + "Default type activator is only capable of instantiating a type if it has a public parameterless constructor." + + Environment.NewLine + + "To fix this, either add a parameterless constructor to the type or configure a custom activator on the application.", + ex + ); + } + } + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/DelegateTypeActivator.cs b/CliFx/Infrastructure/DelegateTypeActivator.cs new file mode 100644 index 0000000..d8a8c66 --- /dev/null +++ b/CliFx/Infrastructure/DelegateTypeActivator.cs @@ -0,0 +1,38 @@ +using System; +using CliFx.Exceptions; + +namespace CliFx.Infrastructure +{ + /// + /// Implementation of that instantiates an object + /// by using a predefined function. + /// + public class DelegateTypeActivator : ITypeActivator + { + private readonly Func _func; + + /// + /// Initializes an instance of . + /// + public DelegateTypeActivator(Func func) => _func = func; + + /// + public object CreateInstance(Type type) + { + var instance = _func(type); + + if (instance is null) + { + throw CliFxException.InternalError( + $"Failed to create an instance of type `{type.FullName}`, received instead." + + Environment.NewLine + + "To fix this, ensure that the provided type activator is configured correctly, as it's not expected to return ." + + Environment.NewLine + + "If you are relying on a dependency container, this error may indicate that the specified type has not been registered." + ); + } + + return instance; + } + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/FakeConsole.cs b/CliFx/Infrastructure/FakeConsole.cs new file mode 100644 index 0000000..b512478 --- /dev/null +++ b/CliFx/Infrastructure/FakeConsole.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Threading; + +namespace CliFx.Infrastructure +{ + /// + /// Implementation of that uses the provided fake + /// standard input, output, and error streams. + /// + /// + /// Use this implementation in tests to verify how a command interacts with the console. + /// + public class FakeConsole : IConsole, IDisposable + { + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + /// + public ConsoleReader Input { get; } + + /// + public bool IsInputRedirected => true; + + /// + public ConsoleWriter Output { get; } + + /// + public bool IsOutputRedirected => true; + + /// + public ConsoleWriter Error { get; } + + /// + public bool IsErrorRedirected => true; + + /// + public ConsoleColor ForegroundColor { get; set; } = ConsoleColor.Gray; + + /// + public ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black; + + /// + public void ResetColor() + { + ForegroundColor = ConsoleColor.Gray; + BackgroundColor = ConsoleColor.Black; + } + + /// + public int CursorLeft { get; set; } + + /// + public int CursorTop { get; set; } + + /// + /// Initializes an instance of . + /// + public FakeConsole(Stream? input = null, Stream? output = null, Stream? error = null) + { + Input = ConsoleReader.Create(this, input); + Output = ConsoleWriter.Create(this, output); + Error = ConsoleWriter.Create(this, error); + } + + /// + public CancellationToken RegisterCancellationHandler() => _cancellationTokenSource.Token; + + /// + /// Sends a cancellation signal to the currently executing command. + /// + /// + /// If the command is not cancellation-aware (i.e. it doesn't call ), + /// this method will not have any effect. + /// + public void RequestCancellation(TimeSpan? delay = null) + { + // Avoid unnecessary creation of a timer + if (delay is not null && delay > TimeSpan.Zero) + { + _cancellationTokenSource.CancelAfter(delay.Value); + } + else + { + _cancellationTokenSource.Cancel(); + } + } + + /// + public virtual void Dispose() => _cancellationTokenSource.Dispose(); + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/FakeInMemoryConsole.cs b/CliFx/Infrastructure/FakeInMemoryConsole.cs new file mode 100644 index 0000000..df90a81 --- /dev/null +++ b/CliFx/Infrastructure/FakeInMemoryConsole.cs @@ -0,0 +1,104 @@ +using System.IO; + +namespace CliFx.Infrastructure +{ + /// + /// Implementation of that uses fake + /// standard input, output, and error streams backed by in-memory stores. + /// + /// + /// Use this implementation in tests to verify how a command interacts with the console. + /// + public class FakeInMemoryConsole : FakeConsole + { + private readonly MemoryStream _input; + private readonly MemoryStream _output; + private readonly MemoryStream _error; + + private FakeInMemoryConsole(MemoryStream input, MemoryStream output, MemoryStream error) + : base(input, output, error) + { + _input = input; + _output = output; + _error = error; + } + + /// + /// Initializes an instance of . + /// + public FakeInMemoryConsole() + : this(new MemoryStream(), new MemoryStream(), new MemoryStream()) + { + } + + /// + /// Writes data to the input stream. + /// + public void WriteInput(byte[] data) + { + // We want the consumer to be able to read what we wrote + // so we need to seek the stream back to its original + // position after we finish writing. + lock (_input) + { + var lastPosition = _input.Position; + + _input.Write(data); + _input.Flush(); + + _input.Position = lastPosition; + } + } + + /// + /// Writes data to the input stream. + /// + public void WriteInput(string data) => WriteInput( + Input.CurrentEncoding.GetBytes(data) + ); + + /// + /// Reads the data written to the output stream. + /// + public byte[] ReadOutputBytes() + { + lock (_output) + { + _output.Flush(); + return _output.ToArray(); + } + } + + /// + /// Reads the data written to the output stream. + /// + public string ReadOutputString() => Output.Encoding.GetString(ReadOutputBytes()); + + /// + /// Reads the data written to the error stream. + /// + public byte[] ReadErrorBytes() + { + lock (_error) + { + _error.Flush(); + return _error.ToArray(); + } + } + + /// + /// Reads the data written to the error stream. + /// + public string ReadErrorString() => Error.Encoding.GetString(ReadErrorBytes()); + + /// + public override void Dispose() + { + _input.Dispose(); + _output.Dispose(); + _error.Dispose(); + + base.Dispose(); + } + } +} \ No newline at end of file diff --git a/CliFx/Infrastructure/IConsole.cs b/CliFx/Infrastructure/IConsole.cs new file mode 100644 index 0000000..d575e3c --- /dev/null +++ b/CliFx/Infrastructure/IConsole.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading; +using CliFx.Utils; + +namespace CliFx.Infrastructure +{ + /// + /// Abstraction for the console layer. + /// + public interface IConsole + { + /// + /// Input stream (stdin). + /// + ConsoleReader Input { get; } + + /// + /// Whether the input stream is redirected. + /// + bool IsInputRedirected { get; } + + /// + /// Output stream (stdout). + /// + ConsoleWriter Output { get; } + + /// + /// Whether the output stream is redirected. + /// + bool IsOutputRedirected { get; } + + /// + /// Error stream (stderr). + /// + ConsoleWriter Error { get; } + + /// + /// Whether the error stream is redirected. + /// + bool IsErrorRedirected { get; } + + /// + /// Current foreground color. + /// + ConsoleColor ForegroundColor { get; set; } + + /// + /// Current background color. + /// + ConsoleColor BackgroundColor { get; set; } + + /// + /// Resets foreground and background colors to their default values. + /// + void ResetColor(); + + /// + /// Cursor left offset. + /// + int CursorLeft { get; set; } + + /// + /// Cursor top offset. + /// + int CursorTop { get; set; } + + /// + /// Registers a handler for the interrupt signal (Ctrl+C) on the console and returns + /// a token representing the cancellation request. + /// Subsequent calls to this method have no side-effects and return the same token. + /// + /// + /// Calling this method effectively makes the command cancellation-aware, which + /// means that sending an interrupt signal won't immediately terminate the application, + /// but will instead trigger a token that the command can use to exit more gracefully. + /// + /// If the user sends a second interrupt signal after the first one, the application + /// will terminate immediately. + /// + CancellationToken RegisterCancellationHandler(); + } + + /// + /// Extensions for . + /// + public static class ConsoleExtensions + { + /// + /// Sets the specified foreground color and returns an + /// that will reset the color back to its previous value upon disposal. + /// + public static IDisposable WithForegroundColor(this IConsole console, ConsoleColor foregroundColor) + { + var lastColor = console.ForegroundColor; + console.ForegroundColor = foregroundColor; + + return Disposable.Create(() => console.ForegroundColor = lastColor); + } + + /// + /// Sets the specified background color and returns an + /// that will reset the color back to its previous value upon disposal. + /// + public static IDisposable WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor) + { + var lastColor = console.BackgroundColor; + console.BackgroundColor = backgroundColor; + + return Disposable.Create(() => console.BackgroundColor = lastColor); + } + + /// + /// Sets the specified foreground and background colors and returns an + /// that will reset the colors back to their previous values upon disposal. + /// + public static IDisposable WithColors( + this IConsole console, + ConsoleColor foregroundColor, + ConsoleColor backgroundColor) + { + var foregroundColorRegistration = console.WithForegroundColor(foregroundColor); + var backgroundColorRegistration = console.WithBackgroundColor(backgroundColor); + + return Disposable.Create(() => + { + foregroundColorRegistration.Dispose(); + backgroundColorRegistration.Dispose(); + }); + } + } +} \ No newline at end of file diff --git a/CliFx/ITypeActivator.cs b/CliFx/Infrastructure/ITypeActivator.cs similarity index 70% rename from CliFx/ITypeActivator.cs rename to CliFx/Infrastructure/ITypeActivator.cs index edd49fd..f917b2f 100644 --- a/CliFx/ITypeActivator.cs +++ b/CliFx/Infrastructure/ITypeActivator.cs @@ -1,9 +1,9 @@ using System; -namespace CliFx +namespace CliFx.Infrastructure { /// - /// Abstraction for a service that can initialize objects at runtime. + /// Abstraction for a service that can instantiate objects at runtime. /// public interface ITypeActivator { diff --git a/CliFx/SystemConsole.cs b/CliFx/Infrastructure/SystemConsole.cs similarity index 61% rename from CliFx/SystemConsole.cs rename to CliFx/Infrastructure/SystemConsole.cs index 316a491..314c135 100644 --- a/CliFx/SystemConsole.cs +++ b/CliFx/Infrastructure/SystemConsole.cs @@ -1,30 +1,29 @@ using System; -using System.IO; using System.Threading; -namespace CliFx +namespace CliFx.Infrastructure { /// - /// Implementation of that wraps the default system console. + /// Implementation of that represents the real system console. /// - public partial class SystemConsole : IConsole + public class SystemConsole : IConsole, IDisposable { private CancellationTokenSource? _cancellationTokenSource; /// - public StreamReader Input { get; } + public ConsoleReader Input { get; } /// public bool IsInputRedirected => Console.IsInputRedirected; /// - public StreamWriter Output { get; } + public ConsoleWriter Output { get; } /// public bool IsOutputRedirected => Console.IsOutputRedirected; /// - public StreamWriter Error { get; } + public ConsoleWriter Error { get; } /// public bool IsErrorRedirected => Console.IsErrorRedirected; @@ -48,9 +47,9 @@ namespace CliFx /// public SystemConsole() { - Input = WrapInput(Console.OpenStandardInput()); - Output = WrapOutput(Console.OpenStandardOutput()); - Error = WrapOutput(Console.OpenStandardError()); + Input = ConsoleReader.Create(this, Console.OpenStandardInput()); + Output = ConsoleWriter.Create(this, Console.OpenStandardOutput()); + Error = ConsoleWriter.Create(this, Console.OpenStandardError()); } /// @@ -71,16 +70,16 @@ namespace CliFx } /// - public CancellationToken GetCancellationToken() + public CancellationToken RegisterCancellationHandler() { - if (_cancellationTokenSource != null) + if (_cancellationTokenSource is not null) return _cancellationTokenSource.Token; var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, args) => { - // If cancellation hasn't been requested yet - cancel shutdown and fire the token + // Don't delay cancellation more than once if (!cts.IsCancellationRequested) { args.Cancel = true; @@ -90,18 +89,15 @@ namespace CliFx return (_cancellationTokenSource = cts).Token; } - } - public partial class SystemConsole - { - private static StreamReader WrapInput(Stream? stream) => - stream != null - ? new StreamReader(Stream.Synchronized(stream), Console.InputEncoding, false) - : StreamReader.Null; + /// + public void Dispose() + { + _cancellationTokenSource?.Dispose(); - private static StreamWriter WrapOutput(Stream? stream) => - stream != null - ? new StreamWriter(Stream.Synchronized(stream), Console.OutputEncoding) {AutoFlush = true} - : StreamWriter.Null; + Input.Dispose(); + Output.Dispose(); + Error.Dispose(); + } } } \ No newline at end of file diff --git a/CliFx/Input/CommandInput.cs b/CliFx/Input/CommandInput.cs new file mode 100644 index 0000000..45d514d --- /dev/null +++ b/CliFx/Input/CommandInput.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using CliFx.Utils.Extensions; + +namespace CliFx.Input +{ + internal partial class CommandInput + { + public string? CommandName { get; } + + public IReadOnlyList Directives { get; } + + public IReadOnlyList Parameters { get; } + + public IReadOnlyList Options { get; } + + public IReadOnlyList EnvironmentVariables { get; } + + public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective); + + public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective); + + public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption); + + public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption); + + public CommandInput( + string? commandName, + IReadOnlyList directives, + IReadOnlyList parameters, + IReadOnlyList options, + IReadOnlyList environmentVariables) + { + CommandName = commandName; + Directives = directives; + Parameters = parameters; + Options = options; + EnvironmentVariables = environmentVariables; + } + } + + internal partial class CommandInput + { + private static IReadOnlyList ParseDirectives( + IReadOnlyList commandLineArguments, + ref int index) + { + var result = new List(); + + // Consume all consecutive directive arguments + for (; index < commandLineArguments.Count; index++) + { + var argument = commandLineArguments[index]; + + // Break on the first non-directive argument + if (!argument.StartsWith('[') || !argument.EndsWith(']')) + break; + + var directiveName = argument.Substring(1, argument.Length - 2); + result.Add(new DirectiveInput(directiveName)); + } + + return result; + } + + private static string? ParseCommandName( + IReadOnlyList commandLineArguments, + ISet commandNames, + ref int index) + { + var potentialCommandNameComponents = new List(); + var commandName = default(string?); + + var lastIndex = index; + + // Append arguments to a buffer until we find the longest sequence + // that represents a valid command name. + for (var i = index; i < commandLineArguments.Count; i++) + { + var argument = commandLineArguments[i]; + + potentialCommandNameComponents.Add(argument); + + var potentialCommandName = potentialCommandNameComponents.JoinToString(" "); + if (commandNames.Contains(potentialCommandName)) + { + // Record the position but continue the loop in case + // we find a longer (more specific) match. + commandName = potentialCommandName; + lastIndex = i; + } + } + + // Move the index to the position where the command name ended + if (!string.IsNullOrWhiteSpace(commandName)) + index = lastIndex + 1; + + return commandName; + } + + private static IReadOnlyList ParseParameters( + IReadOnlyList commandLineArguments, + ref int index) + { + var result = new List(); + + // Consume all arguments until first option identifier + for (; index < commandLineArguments.Count; index++) + { + var argument = commandLineArguments[index]; + + var isOptionIdentifier = + // Name + argument.StartsWith("--", StringComparison.Ordinal) && + argument.Length > 2 && + char.IsLetter(argument[2]) || + // Short name + argument.StartsWith('-') && + argument.Length > 1 && + char.IsLetter(argument[1]); + + // Break on first option identifier + if (isOptionIdentifier) + break; + + result.Add(new ParameterInput(argument)); + } + + return result; + } + + private static IReadOnlyList ParseOptions( + IReadOnlyList commandLineArguments, + ref int index) + { + var result = new List(); + + var lastOptionIdentifier = default(string?); + var lastOptionValues = new List(); + + // Consume and group all remaining arguments into options + for (; index < commandLineArguments.Count; index++) + { + var argument = commandLineArguments[index]; + + // Name + if (argument.StartsWith("--", StringComparison.Ordinal) && + argument.Length > 2 && + char.IsLetter(argument[2])) + { + // Flush previous + if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + + lastOptionIdentifier = argument.Substring(2); + lastOptionValues = new List(); + } + // Short name + else if (argument.StartsWith('-') && + argument.Length > 1 && + char.IsLetter(argument[1])) + { + foreach (var alias in argument.Substring(1)) + { + // Flush previous + if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + + lastOptionIdentifier = alias.AsString(); + lastOptionValues = new List(); + } + } + // Value + else if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) + { + lastOptionValues.Add(argument); + } + } + + // Flush last option + if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + + return result; + } + + public static CommandInput Parse( + IReadOnlyList commandLineArguments, + IReadOnlyDictionary environmentVariables, + IReadOnlyList availableCommandNames) + { + var index = 0; + + var parsedDirectives = ParseDirectives( + commandLineArguments, + ref index + ); + + var parsedCommandName = ParseCommandName( + commandLineArguments, + availableCommandNames.ToHashSet(StringComparer.OrdinalIgnoreCase), + ref index + ); + + var parsedParameters = ParseParameters( + commandLineArguments, + ref index + ); + + var parsedOptions = ParseOptions( + commandLineArguments, + ref index + ); + + var parsedEnvironmentVariables = environmentVariables + .Select(kvp => new EnvironmentVariableInput(kvp.Key, kvp.Value)) + .ToArray(); + + return new CommandInput( + parsedCommandName, + parsedDirectives, + parsedParameters, + parsedOptions, + parsedEnvironmentVariables + ); + } + } +} \ No newline at end of file diff --git a/CliFx/Input/DirectiveInput.cs b/CliFx/Input/DirectiveInput.cs new file mode 100644 index 0000000..928aa10 --- /dev/null +++ b/CliFx/Input/DirectiveInput.cs @@ -0,0 +1,17 @@ +using System; + +namespace CliFx.Input +{ + internal class DirectiveInput + { + public string Name { get; } + + public bool IsDebugDirective => + string.Equals(Name, "debug", StringComparison.OrdinalIgnoreCase); + + public bool IsPreviewDirective => + string.Equals(Name, "preview", StringComparison.OrdinalIgnoreCase); + + public DirectiveInput(string name) => Name = name; + } +} \ No newline at end of file diff --git a/CliFx/Input/EnvironmentVariableInput.cs b/CliFx/Input/EnvironmentVariableInput.cs new file mode 100644 index 0000000..8e9c8c6 --- /dev/null +++ b/CliFx/Input/EnvironmentVariableInput.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.IO; + +namespace CliFx.Input +{ + internal class EnvironmentVariableInput + { + public string Name { get; } + + public string Value { get; } + + public EnvironmentVariableInput(string name, string value) + { + Name = name; + Value = value; + } + + public IReadOnlyList SplitValues() => Value.Split(Path.PathSeparator); + } +} \ No newline at end of file diff --git a/CliFx/Input/OptionInput.cs b/CliFx/Input/OptionInput.cs new file mode 100644 index 0000000..38b1c2d --- /dev/null +++ b/CliFx/Input/OptionInput.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using CliFx.Schema; + +namespace CliFx.Input +{ + internal class OptionInput + { + public string Identifier { get; } + + public IReadOnlyList Values { get; } + + public bool IsHelpOption => + OptionSchema.HelpOption.MatchesIdentifier(Identifier); + + public bool IsVersionOption => + OptionSchema.VersionOption.MatchesIdentifier(Identifier); + + public OptionInput(string identifier, IReadOnlyList values) + { + Identifier = identifier; + Values = values; + } + + public string GetFormattedIdentifier() => Identifier switch + { + {Length: >= 2} => "--" + Identifier, + _ => '-' + Identifier + }; + } +} \ No newline at end of file diff --git a/CliFx/Input/ParameterInput.cs b/CliFx/Input/ParameterInput.cs new file mode 100644 index 0000000..ae3783c --- /dev/null +++ b/CliFx/Input/ParameterInput.cs @@ -0,0 +1,11 @@ +namespace CliFx.Input +{ + internal class ParameterInput + { + public string Value { get; } + + public ParameterInput(string value) => Value = value; + + public string GetFormattedIdentifier() => $"<{Value}>"; + } +} \ No newline at end of file diff --git a/CliFx/Internal/Extensions/CollectionExtensions.cs b/CliFx/Internal/Extensions/CollectionExtensions.cs deleted file mode 100644 index 9ef38fd..0000000 --- a/CliFx/Internal/Extensions/CollectionExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; - -namespace CliFx.Internal.Extensions -{ - internal static class CollectionExtensions - { - public static void RemoveRange(this ICollection source, IEnumerable items) - { - foreach (var item in items) - source.Remove(item); - } - } -} \ No newline at end of file diff --git a/CliFx/Schema/ApplicationSchema.cs b/CliFx/Schema/ApplicationSchema.cs new file mode 100644 index 0000000..9b4592a --- /dev/null +++ b/CliFx/Schema/ApplicationSchema.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CliFx.Utils.Extensions; + +namespace CliFx.Schema +{ + internal partial class ApplicationSchema + { + public IReadOnlyList Commands { get; } + + public ApplicationSchema(IReadOnlyList commands) + { + Commands = commands; + } + + public IReadOnlyList GetCommandNames() => Commands + .Select(c => c.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .ToArray()!; + + public CommandSchema? TryFindDefaultCommand() => + Commands.FirstOrDefault(c => c.IsDefault); + + public CommandSchema? TryFindCommand(string? commandName) => + Commands.FirstOrDefault(c => c.MatchesName(commandName)); + + private IReadOnlyList GetDescendantCommands( + IReadOnlyList potentialParentCommandSchemas, + string? parentCommandName) + { + var result = new List(); + + foreach (var potentialParentCommandSchema in potentialParentCommandSchemas) + { + // Default commands can't be descendant of anything + if (string.IsNullOrWhiteSpace(potentialParentCommandSchema.Name)) + continue; + + // Command can't be its own descendant + if (potentialParentCommandSchema.MatchesName(parentCommandName)) + continue; + + var isDescendant = + // Every command is a descendant of the default command + string.IsNullOrWhiteSpace(parentCommandName) || + // Otherwise a command is a descendant if it starts with the same name segments + potentialParentCommandSchema.Name.StartsWith( + parentCommandName + ' ', + StringComparison.OrdinalIgnoreCase + ); + + if (isDescendant) + result.Add(potentialParentCommandSchema); + } + + return result; + } + + public IReadOnlyList GetDescendantCommands(string? parentCommandName) => + GetDescendantCommands(Commands, parentCommandName); + + public IReadOnlyList GetChildCommands(string? parentCommandName) + { + var descendants = GetDescendantCommands(parentCommandName); + + var result = descendants.ToList(); + + // Filter out descendants of descendants, leave only direct children + foreach (var descendant in descendants) + { + result.RemoveRange( + GetDescendantCommands(descendants, descendant.Name) + ); + } + + return result; + } + } + + internal partial class ApplicationSchema + { + public static ApplicationSchema Resolve(IReadOnlyList commandTypes) => new( + commandTypes.Select(CommandSchema.Resolve).ToArray() + ); + } +} \ No newline at end of file diff --git a/CliFx/Schema/BindablePropertyDescriptor.cs b/CliFx/Schema/BindablePropertyDescriptor.cs new file mode 100644 index 0000000..289e99f --- /dev/null +++ b/CliFx/Schema/BindablePropertyDescriptor.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using CliFx.Utils.Extensions; + +namespace CliFx.Schema +{ + internal class BindablePropertyDescriptor : IPropertyDescriptor + { + private readonly PropertyInfo _property; + + public Type Type => _property.PropertyType; + + public BindablePropertyDescriptor(PropertyInfo property) => _property = property; + + public object? GetValue(ICommand commandInstance) => + _property.GetValue(commandInstance); + + public void SetValue(ICommand commandInstance, object? value) => + _property.SetValue(commandInstance, value); + + public IReadOnlyList GetValidValues() + { + var underlyingType = Type.TryGetNullableUnderlyingType() ?? Type; + + // We can only get valid values for enums + if (underlyingType.IsEnum) + return Enum.GetNames(underlyingType); + + return Array.Empty(); + } + } +} \ No newline at end of file diff --git a/CliFx/Schema/CommandSchema.cs b/CliFx/Schema/CommandSchema.cs new file mode 100644 index 0000000..952ffbd --- /dev/null +++ b/CliFx/Schema/CommandSchema.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Utils.Extensions; + +namespace CliFx.Schema +{ + internal partial class CommandSchema + { + public Type Type { get; } + + public string? Name { get; } + + public string? Description { get; } + + public IReadOnlyList Parameters { get; } + + public IReadOnlyList Options { get; } + + public bool IsDefault => string.IsNullOrWhiteSpace(Name); + + public bool IsHelpOptionAvailable => Options.Contains(OptionSchema.HelpOption); + + public bool IsVersionOptionAvailable => Options.Contains(OptionSchema.VersionOption); + + public CommandSchema( + Type type, + string? name, + string? description, + IReadOnlyList parameters, + IReadOnlyList options) + { + Type = type; + Name = name; + Description = description; + Parameters = parameters; + Options = options; + } + + public bool MatchesName(string? name) => + !string.IsNullOrWhiteSpace(Name) + ? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase) + : string.IsNullOrWhiteSpace(name); + + public IReadOnlyDictionary GetValues(ICommand instance) + { + var result = new Dictionary(); + + foreach (var parameterSchema in Parameters) + { + var value = parameterSchema.Property.GetValue(instance); + result[parameterSchema] = value; + } + + foreach (var optionSchema in Options) + { + var value = optionSchema.Property.GetValue(instance); + result[optionSchema] = value; + } + + return result; + } + } + + internal partial class CommandSchema + { + public static bool IsCommandType(Type type) => + type.Implements(typeof(ICommand)) && + type.IsDefined(typeof(CommandAttribute)) && + !type.IsAbstract && + !type.IsInterface; + + public static CommandSchema? TryResolve(Type type) + { + if (!IsCommandType(type)) + return null; + + var attribute = type.GetCustomAttribute(); + + var name = attribute?.Name?.Trim(); + var description = attribute?.Description?.Trim(); + + var implicitOptionSchemas = string.IsNullOrWhiteSpace(name) + ? new[] {OptionSchema.HelpOption, OptionSchema.VersionOption} + : new[] {OptionSchema.HelpOption}; + + var parameterSchemas = type.GetProperties() + .Select(ParameterSchema.TryResolve) + .Where(p => p is not null) + .ToArray(); + + var optionSchemas = type.GetProperties() + .Select(OptionSchema.TryResolve) + .Where(o => o is not null) + .Concat(implicitOptionSchemas) + .ToArray(); + + return new CommandSchema( + type, + name, + description, + parameterSchemas!, + optionSchemas! + ); + } + + public static CommandSchema Resolve(Type type) + { + var schema = TryResolve(type); + + if (schema is null) + { + throw CliFxException.InternalError( + $"Type `{type.FullName}` is not a valid command type." + + Environment.NewLine + + "In order to be a valid command type, it must:" + + Environment.NewLine + + $"- Implement `{typeof(ICommand).FullName}`" + + Environment.NewLine + + $"- Be annotated with `{typeof(CommandAttribute).FullName}`" + + Environment.NewLine + + "- Not be an abstract class" + ); + } + + return schema; + } + } +} \ No newline at end of file diff --git a/CliFx/Schema/IMemberSchema.cs b/CliFx/Schema/IMemberSchema.cs new file mode 100644 index 0000000..70dd3bf --- /dev/null +++ b/CliFx/Schema/IMemberSchema.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace CliFx.Schema +{ + internal interface IMemberSchema + { + IPropertyDescriptor Property { get; } + + Type? ConverterType { get; } + + IReadOnlyList ValidatorTypes { get; } + + string GetFormattedIdentifier(); + } + + internal static class MemberSchemaExtensions + { + public static string GetKind(this IMemberSchema memberSchema) => memberSchema switch + { + ParameterSchema => "Parameter", + OptionSchema => "Option", + _ => throw new ArgumentOutOfRangeException(nameof(memberSchema)) + }; + } +} \ No newline at end of file diff --git a/CliFx/Schema/IPropertyDescriptor.cs b/CliFx/Schema/IPropertyDescriptor.cs new file mode 100644 index 0000000..8e63e94 --- /dev/null +++ b/CliFx/Schema/IPropertyDescriptor.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using CliFx.Utils.Extensions; + +namespace CliFx.Schema +{ + internal interface IPropertyDescriptor + { + Type Type { get; } + + object? GetValue(ICommand commandInstance); + + void SetValue(ICommand commandInstance, object? value); + + IReadOnlyList GetValidValues(); + } + + internal static class PropertyDescriptorExtensions + { + public static bool IsScalar(this IPropertyDescriptor propertyDescriptor) => + propertyDescriptor.Type == typeof(string) || + propertyDescriptor.Type.TryGetEnumerableUnderlyingType() is null; + } +} \ No newline at end of file diff --git a/CliFx/Schema/NullPropertyDescriptor.cs b/CliFx/Schema/NullPropertyDescriptor.cs new file mode 100644 index 0000000..c16bf33 --- /dev/null +++ b/CliFx/Schema/NullPropertyDescriptor.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace CliFx.Schema +{ + internal partial class NullPropertyDescriptor : IPropertyDescriptor + { + public Type Type { get; } = typeof(object); + + public object? GetValue(ICommand commandInstance) => null; + + public void SetValue(ICommand commandInstance, object? value) + { + } + + public IReadOnlyList GetValidValues() => Array.Empty(); + } + + internal partial class NullPropertyDescriptor + { + public static NullPropertyDescriptor Instance { get; } = new(); + } +} \ No newline at end of file diff --git a/CliFx/Schema/OptionSchema.cs b/CliFx/Schema/OptionSchema.cs new file mode 100644 index 0000000..af1ca2d --- /dev/null +++ b/CliFx/Schema/OptionSchema.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using CliFx.Attributes; + +namespace CliFx.Schema +{ + internal partial class OptionSchema : IMemberSchema + { + public IPropertyDescriptor Property { get; } + + public string? Name { get; } + + public char? ShortName { get; } + + public string? EnvironmentVariable { get; } + + public bool IsRequired { get; } + + public string? Description { get; } + + public Type? ConverterType { get; } + + public IReadOnlyList ValidatorTypes { get; } + + public OptionSchema( + IPropertyDescriptor property, + string? name, + char? shortName, + string? environmentVariable, + bool isRequired, + string? description, + Type? converterType, + IReadOnlyList validatorTypes) + { + Property = property; + Name = name; + ShortName = shortName; + EnvironmentVariable = environmentVariable; + IsRequired = isRequired; + Description = description; + ConverterType = converterType; + ValidatorTypes = validatorTypes; + } + + public bool MatchesName(string? name) => + !string.IsNullOrWhiteSpace(Name) && + string.Equals(Name, name, StringComparison.OrdinalIgnoreCase); + + public bool MatchesShortName(char? shortName) => + ShortName is not null && + ShortName == shortName; + + public bool MatchesIdentifier(string identifier) => + MatchesName(identifier) || + identifier.Length == 1 && MatchesShortName(identifier[0]); + + public bool MatchesEnvironmentVariable(string environmentVariableName) => + !string.IsNullOrWhiteSpace(EnvironmentVariable) && + string.Equals(EnvironmentVariable, environmentVariableName, StringComparison.Ordinal); + + public string GetFormattedIdentifier() + { + var buffer = new StringBuilder(); + + // Short name + if (ShortName is not null) + { + buffer + .Append('-') + .Append(ShortName); + } + + // Separator + if (!string.IsNullOrWhiteSpace(Name) && ShortName is not null) + { + buffer.Append('|'); + } + + // Name + if (!string.IsNullOrWhiteSpace(Name)) + { + buffer + .Append("--") + .Append(Name); + } + + return buffer.ToString(); + } + } + + internal partial class OptionSchema + { + public static OptionSchema? TryResolve(PropertyInfo property) + { + var attribute = property.GetCustomAttribute(); + if (attribute is null) + return null; + + // The user may mistakenly specify dashes, thinking it's required, so trim them + var name = attribute.Name?.TrimStart('-').Trim(); + var environmentVariable = attribute.EnvironmentVariable?.Trim(); + var description = attribute.Description?.Trim(); + + return new OptionSchema( + new BindablePropertyDescriptor(property), + name, + attribute.ShortName, + environmentVariable, + attribute.IsRequired, + description, + attribute.Converter, + attribute.Validators + ); + } + } + + internal partial class OptionSchema + { + public static OptionSchema HelpOption { get; } = new( + NullPropertyDescriptor.Instance, + "help", + 'h', + null, + false, + "Shows help text.", + null, + Array.Empty() + ); + + public static OptionSchema VersionOption { get; } = new( + NullPropertyDescriptor.Instance, + "version", + null, + null, + false, + "Shows version information.", + null, + Array.Empty() + ); + } +} \ No newline at end of file diff --git a/CliFx/Schema/ParameterSchema.cs b/CliFx/Schema/ParameterSchema.cs new file mode 100644 index 0000000..3c51856 --- /dev/null +++ b/CliFx/Schema/ParameterSchema.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using CliFx.Attributes; + +namespace CliFx.Schema +{ + internal partial class ParameterSchema : IMemberSchema + { + public IPropertyDescriptor Property { get; } + + public int Order { get; } + + public string Name { get; } + + public string? Description { get; } + + public Type? ConverterType { get; } + + public IReadOnlyList ValidatorTypes { get; } + + public ParameterSchema( + IPropertyDescriptor property, + int order, + string name, + string? description, + Type? converterType, + IReadOnlyList validatorTypes) + { + Property = property; + Order = order; + Name = name; + Description = description; + ConverterType = converterType; + ValidatorTypes = validatorTypes; + } + + public string GetFormattedIdentifier() => Property.IsScalar() + ? $"<{Name}>" + : $"<{Name}...>"; + } + + internal partial class ParameterSchema + { + public static ParameterSchema? TryResolve(PropertyInfo property) + { + var attribute = property.GetCustomAttribute(); + if (attribute is null) + return null; + + var name = attribute.Name?.Trim() ?? property.Name.ToLowerInvariant(); + var description = attribute.Description?.Trim(); + + return new ParameterSchema( + new BindablePropertyDescriptor(property), + attribute.Order, + name, + description, + attribute.Converter, + attribute.Validators + ); + } + } +} \ No newline at end of file diff --git a/CliFx/Utilities/MemoryStreamWriter.cs b/CliFx/Utilities/MemoryStreamWriter.cs deleted file mode 100644 index a39e1d2..0000000 --- a/CliFx/Utilities/MemoryStreamWriter.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.IO; -using System.Text; - -namespace CliFx.Utilities -{ - /// - /// Implementation of with a as a backing store. - /// - public class MemoryStreamWriter : StreamWriter - { - private new MemoryStream BaseStream => (MemoryStream) base.BaseStream; - - /// - /// Initializes an instance of . - /// - public MemoryStreamWriter(Encoding encoding) - : base(new MemoryStream(), encoding) - { - } - - /// - /// Initializes an instance of . - /// - public MemoryStreamWriter() - : base(new MemoryStream()) - { - } - - /// - /// Gets the bytes written to the underlying stream. - /// - public byte[] GetBytes() - { - Flush(); - return BaseStream.ToArray(); - } - - /// - /// Gets the string written to the underlying stream. - /// - public string GetString() => Encoding.GetString(GetBytes()); - } -} \ No newline at end of file diff --git a/CliFx/Utilities/ProgressTicker.cs b/CliFx/Utilities/ProgressTicker.cs deleted file mode 100644 index 7d8e667..0000000 --- a/CliFx/Utilities/ProgressTicker.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; - -namespace CliFx.Utilities -{ - /// - /// Utility for rendering current progress to the console that erases and rewrites output on every tick. - /// - public class ProgressTicker : IProgress - { - private readonly IConsole _console; - - private int? _originalCursorLeft; - private int? _originalCursorTop; - - /// - /// Initializes an instance of . - /// - public ProgressTicker(IConsole console) => _console = console; - - private void RenderProgress(double progress) - { - if (_originalCursorLeft != null && _originalCursorTop != null) - { - _console.CursorLeft = _originalCursorLeft.Value; - _console.CursorTop = _originalCursorTop.Value; - } - else - { - _originalCursorLeft = _console.CursorLeft; - _originalCursorTop = _console.CursorTop; - } - - var str = progress.ToString("P2", _console.Output.FormatProvider); - _console.Output.Write(str); - } - - /// - /// Erases previous output and renders new progress to the console. - /// If stdout is redirected, this method returns without doing anything. - /// - public void Report(double progress) - { - // We don't do anything if stdout is redirected to avoid polluting output - // when there's no active console window. - if (!_console.IsOutputRedirected) - { - RenderProgress(progress); - } - } - } - - /// - /// Extensions for . - /// - public static class ProgressTickerExtensions - { - /// - /// Creates a bound to this console. - /// - public static ProgressTicker CreateProgressTicker(this IConsole console) => new(console); - } -} \ No newline at end of file diff --git a/CliFx/Utils/Disposable.cs b/CliFx/Utils/Disposable.cs new file mode 100644 index 0000000..50be370 --- /dev/null +++ b/CliFx/Utils/Disposable.cs @@ -0,0 +1,18 @@ +using System; + +namespace CliFx.Utils +{ + internal partial class Disposable : IDisposable + { + private readonly Action _dispose; + + public Disposable(Action dispose) => _dispose = dispose; + + public void Dispose() => _dispose(); + } + + internal partial class Disposable + { + public static IDisposable Create(Action dispose) => new Disposable(dispose); + } +} \ No newline at end of file diff --git a/CliFx/Utils/Extensions/CollectionExtensions.cs b/CliFx/Utils/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..aaccfe6 --- /dev/null +++ b/CliFx/Utils/Extensions/CollectionExtensions.cs @@ -0,0 +1,22 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace CliFx.Utils.Extensions +{ + internal static class CollectionExtensions + { + public static void RemoveRange(this ICollection source, IEnumerable items) + { + foreach (var item in items) + source.Remove(item); + } + + public static Dictionary ToDictionary( + this IDictionary dictionary, + IEqualityComparer comparer) => + dictionary + .Cast() + .ToDictionary(entry => (TKey) entry.Key, entry => (TValue) entry.Value, comparer)!; + } +} \ No newline at end of file diff --git a/CliFx/Internal/Extensions/StringExtensions.cs b/CliFx/Utils/Extensions/StringExtensions.cs similarity index 55% rename from CliFx/Internal/Extensions/StringExtensions.cs rename to CliFx/Utils/Extensions/StringExtensions.cs index ce5cb8a..0449f47 100644 --- a/CliFx/Internal/Extensions/StringExtensions.cs +++ b/CliFx/Utils/Extensions/StringExtensions.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; -using System.Text; -namespace CliFx.Internal.Extensions +namespace CliFx.Utils.Extensions { internal static class StringExtensions { @@ -15,15 +14,13 @@ namespace CliFx.Internal.Extensions public static string AsString(this char c) => c.Repeat(1); - public static string Quote(this string str) => $"\"{str}\""; + public static string JoinToString(this IEnumerable source, string separator) => + string.Join(separator, source); - public static string JoinToString(this IEnumerable source, string separator) => string.Join(separator, source); - - public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => - builder.Length > 0 ? builder.Append(value) : builder; - - public static string ToFormattableString(this object obj, - IFormatProvider? formatProvider = null, string? format = null) => + public static string ToString( + this object obj, + IFormatProvider? formatProvider = null, + string? format = null) => obj is IFormattable formattable ? formattable.ToString(format, formatProvider) : obj.ToString(); diff --git a/CliFx/Internal/Extensions/TypeExtensions.cs b/CliFx/Utils/Extensions/TypeExtensions.cs similarity index 66% rename from CliFx/Internal/Extensions/TypeExtensions.cs rename to CliFx/Utils/Extensions/TypeExtensions.cs index 4c93a90..03be3db 100644 --- a/CliFx/Internal/Extensions/TypeExtensions.cs +++ b/CliFx/Utils/Extensions/TypeExtensions.cs @@ -4,15 +4,12 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -namespace CliFx.Internal.Extensions +namespace CliFx.Utils.Extensions { internal static class TypeExtensions { - public static object CreateInstance(this Type type) => Activator.CreateInstance(type); - - public static T CreateInstance(this Type type) => (T) type.CreateInstance(); - - public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType); + public static bool Implements(this Type type, Type interfaceType) => + type.GetInterfaces().Contains(interfaceType); public static Type? TryGetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type); @@ -30,18 +27,11 @@ namespace CliFx.Internal.Extensions return type .GetInterfaces() .Select(TryGetEnumerableUnderlyingType) - .Where(t => t != null) + .Where(t => t is not null) .OrderByDescending(t => t != typeof(object)) // prioritize more specific types .FirstOrDefault(); } - public static MethodInfo GetToStringMethod(this Type type) => - // ToString() with no params always exists - type.GetMethod(nameof(ToString), Type.EmptyTypes)!; - - public static bool IsToStringOverriden(this Type type) => - type.GetToStringMethod() != typeof(object).GetToStringMethod(); - public static MethodInfo? TryGetStaticParseMethod(this Type type, bool withFormatProvider = false) { var argumentTypes = withFormatProvider @@ -63,5 +53,32 @@ namespace CliFx.Internal.Extensions return array; } + + public static bool IsToStringOverriden(this Type type) => + type.GetMethod(nameof(ToString), Type.EmptyTypes) != + typeof(object).GetMethod(nameof(ToString), Type.EmptyTypes); + + // Types supported by `Convert.ChangeType(...)` + private static readonly HashSet ConvertibleTypes = new() + { + typeof(bool), + typeof(char), + typeof(sbyte), + typeof(byte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal), + typeof(DateTime), + typeof(string), + typeof(object) + }; + + public static bool IsConvertible(this Type type) => ConvertibleTypes.Contains(type); } } \ No newline at end of file diff --git a/CliFx/Internal/Extensions/VersionExtensions.cs b/CliFx/Utils/Extensions/VersionExtensions.cs similarity index 86% rename from CliFx/Internal/Extensions/VersionExtensions.cs rename to CliFx/Utils/Extensions/VersionExtensions.cs index 092bc1c..0b2cfcc 100644 --- a/CliFx/Internal/Extensions/VersionExtensions.cs +++ b/CliFx/Utils/Extensions/VersionExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace CliFx.Internal.Extensions +namespace CliFx.Utils.Extensions { internal static class VersionExtensions { diff --git a/CliFx/Internal/Polyfills.cs b/CliFx/Utils/Polyfills.cs similarity index 86% rename from CliFx/Internal/Polyfills.cs rename to CliFx/Utils/Polyfills.cs index 6596369..2299161 100644 --- a/CliFx/Internal/Polyfills.cs +++ b/CliFx/Utils/Polyfills.cs @@ -1,11 +1,10 @@ // ReSharper disable CheckNamespace -// Polyfills to bridge the missing APIs in older versions of the framework/standard. - +#if NETSTANDARD2_0 using System; using System.Collections.Generic; +using System.IO; -#if NETSTANDARD2_0 internal static partial class PolyfillExtensions { public static bool StartsWith(this string str, char c) => @@ -30,6 +29,13 @@ internal static partial class PolyfillExtensions dic.TryGetValue(key!, out var result) ? result! : default!; } +internal static partial class PolyfillExtensions +{ + public static void Write(this Stream stream, byte[] buffer) => + stream.Write(buffer, 0, buffer.Length); +} + + namespace System.Linq { internal static class PolyfillExtensions diff --git a/CliFx/Internal/ProcessEx.cs b/CliFx/Utils/ProcessEx.cs similarity index 90% rename from CliFx/Internal/ProcessEx.cs rename to CliFx/Utils/ProcessEx.cs index 383df5b..0eb05e4 100644 --- a/CliFx/Internal/ProcessEx.cs +++ b/CliFx/Utils/ProcessEx.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace CliFx.Internal +namespace CliFx.Utils { internal static class ProcessEx { diff --git a/CliFx/Internal/StackFrame.cs b/CliFx/Utils/StackFrame.cs similarity index 91% rename from CliFx/Internal/StackFrame.cs rename to CliFx/Utils/StackFrame.cs index 0ee05b4..5d794dc 100644 --- a/CliFx/Internal/StackFrame.cs +++ b/CliFx/Utils/StackFrame.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -using CliFx.Internal.Extensions; +using CliFx.Utils.Extensions; -namespace CliFx.Internal +namespace CliFx.Utils { internal class StackFrameParameter { @@ -98,7 +98,13 @@ namespace CliFx.Internal if (!isSuccess) { - throw new FormatException("Could not parse stack trace."); + // If parsing fails, we include the original stacktrace in the + // exception so that it's shown to the user. + throw new FormatException( + "Could not parse stacktrace:" + + Environment.NewLine + + stackTrace + ); } return from m in matches diff --git a/CliFx/VirtualConsole.cs b/CliFx/VirtualConsole.cs deleted file mode 100644 index 3dc5e29..0000000 --- a/CliFx/VirtualConsole.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using CliFx.Utilities; - -namespace CliFx -{ - /// - /// Implementation of that routes all data to preconfigured streams. - /// Does not leak to system console in any way. - /// Use this class as a substitute for system console when running tests. - /// - public partial class VirtualConsole : IConsole - { - private readonly CancellationToken _cancellationToken; - - /// - public StreamReader Input { get; } - - /// - public bool IsInputRedirected { get; } - - /// - public StreamWriter Output { get; } - - /// - public bool IsOutputRedirected { get; } - - /// - public StreamWriter Error { get; } - - /// - public bool IsErrorRedirected { get; } - - /// - public ConsoleColor ForegroundColor { get; set; } = ConsoleColor.Gray; - - /// - public ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black; - - /// - public void ResetColor() - { - ForegroundColor = ConsoleColor.Gray; - BackgroundColor = ConsoleColor.Black; - } - - /// - public int CursorLeft { get; set; } - - /// - public int CursorTop { get; set; } - - /// - public CancellationToken GetCancellationToken() => _cancellationToken; - - /// - /// Initializes an instance of . - /// Use named parameters to specify the streams you want to override. - /// - public VirtualConsole( - StreamReader? input = null, bool isInputRedirected = true, - StreamWriter? output = null, bool isOutputRedirected = true, - StreamWriter? error = null, bool isErrorRedirected = true, - CancellationToken cancellationToken = default) - { - Input = input ?? StreamReader.Null; - IsInputRedirected = isInputRedirected; - Output = output ?? StreamWriter.Null; - IsOutputRedirected = isOutputRedirected; - Error = error ?? StreamWriter.Null; - IsErrorRedirected = isErrorRedirected; - _cancellationToken = cancellationToken; - } - - /// - /// Initializes an instance of . - /// Use named parameters to specify the streams you want to override. - /// - public VirtualConsole( - Stream? input = null, bool isInputRedirected = true, - Stream? output = null, bool isOutputRedirected = true, - Stream? error = null, bool isErrorRedirected = true, - CancellationToken cancellationToken = default) - : this( - WrapInput(input), isInputRedirected, - WrapOutput(output), isOutputRedirected, - WrapOutput(error), isErrorRedirected, - cancellationToken) - { - } - } - - public partial class VirtualConsole - { - private static StreamReader WrapInput(Stream? stream) => - stream != null - ? new StreamReader(Stream.Synchronized(stream), Console.InputEncoding, false) - : StreamReader.Null; - - private static StreamWriter WrapOutput(Stream? stream) => - stream != null - ? new StreamWriter(Stream.Synchronized(stream), Console.OutputEncoding) {AutoFlush = true} - : StreamWriter.Null; - - /// - /// Creates a that uses in-memory output and error streams. - /// Use the exposed streams to easily get the current output. - /// - public static (VirtualConsole console, MemoryStreamWriter output, MemoryStreamWriter error) CreateBuffered( - CancellationToken cancellationToken = default) - { - // Memory streams don't need to be disposed - var output = new MemoryStreamWriter(Console.OutputEncoding); - var error = new MemoryStreamWriter(Console.OutputEncoding); - - var console = new VirtualConsole( - output: output, - error: error, - cancellationToken: cancellationToken - ); - - return (console, output, error); - } - } -} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index a729670..7fe476d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,4 +16,4 @@ - + \ No newline at end of file diff --git a/License.txt b/License.txt index c35f866..2f666fa 100644 --- a/License.txt +++ b/License.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2020 Alexey Golub +Copyright (c) 2019-2021 Alexey Golub Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Readme.md b/Readme.md index 790a259..b4f2579 100644 --- a/Readme.md +++ b/Readme.md @@ -744,29 +744,6 @@ test Environment variables can be used as fallback for options of enumerable types too. In this case, the value of the variable will be split by `Path.PathSeparator` (which is `;` on Windows, `:` on Linux). -## Benchmarks - -Here's how CliFx's execution overhead compares to that of other libraries. - -```ini -BenchmarkDotNet=v0.12.0, OS=Windows 10.0.14393.3443 (1607/AnniversaryUpdate/Redstone1) -Intel Core i5-4460 CPU 3.20GHz (Haswell), 1 CPU, 4 logical and 4 physical cores -Frequency=3124994 Hz, Resolution=320.0006 ns, Timer=TSC -.NET Core SDK=3.1.100 - [Host] : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT - DefaultJob : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT -``` - -| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | -| ------------------------------------ | ----------: | --------: | ---------: | ----: | ------: | ---: | -| CommandLineParser | 24.79 us | 0.166 us | 0.155 us | 0.49 | 0.00 | 1 | -| CliFx | 50.27 us | 0.248 us | 0.232 us | 1.00 | 0.00 | 2 | -| Clipr | 160.22 us | 0.817 us | 0.764 us | 3.19 | 0.02 | 3 | -| McMaster.Extensions.CommandLineUtils | 166.45 us | 1.111 us | 1.039 us | 3.31 | 0.03 | 4 | -| System.CommandLine | 170.27 us | 0.599 us | 0.560 us | 3.39 | 0.02 | 5 | -| PowerArgs | 306.12 us | 1.495 us | 1.398 us | 6.09 | 0.03 | 6 | -| Cocona | 1,856.07 us | 48.727 us | 141.367 us | 37.88 | 2.60 | 7 | - ## Etymology CliFx is made out of "Cli" for "Command Line Interface" and "Fx" for "Framework". It's pronounced as "cliff ex".