From d25873ee103cde1c95d944610b564aef61ac474d Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Sat, 25 Apr 2020 18:03:21 +0300 Subject: [PATCH] Add CliFx.Analyzers (#50) --- .github/workflows/CD.yml | 8 +- .github/workflows/CI.yml | 6 + CliFx.Analyzers.Tests/AnalyzerTestCase.cs | 43 ++ .../CliFx.Analyzers.Tests.csproj | 29 ++ .../CommandSchemaAnalyzerTests.cs | 489 ++++++++++++++++++ .../ConsoleUsageAnalyzerTests.cs | 144 ++++++ .../Internal/AnalyzerAssertions.cs | 107 ++++ CliFx.Analyzers/CliFx.Analyzers.csproj | 26 + CliFx.Analyzers/CommandSchemaAnalyzer.cs | 298 +++++++++++ CliFx.Analyzers/ConsoleUsageAnalyzer.cs | 80 +++ CliFx.Analyzers/DiagnosticDescriptors.cs | 79 +++ CliFx.Analyzers/Internal/RoslynExtensions.cs | 11 + CliFx.Analyzers/KnownSymbols.cs | 37 ++ CliFx.Demo/CliFx.Demo.csproj | 3 +- CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj | 1 + CliFx.Tests/CliFx.Tests.csproj | 2 +- CliFx.sln | 32 +- CliFx/CliFx.csproj | 37 +- CliFx/Internal/Polyfills.cs | 2 +- CliFx/Internal/StringExtensions.cs | 14 + .../{Extensions.cs => TypeExtensions.cs} | 10 +- Readme.md | 1 + 22 files changed, 1426 insertions(+), 33 deletions(-) create mode 100644 CliFx.Analyzers.Tests/AnalyzerTestCase.cs create mode 100644 CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj create mode 100644 CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs create mode 100644 CliFx.Analyzers.Tests/ConsoleUsageAnalyzerTests.cs create mode 100644 CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs create mode 100644 CliFx.Analyzers/CliFx.Analyzers.csproj create mode 100644 CliFx.Analyzers/CommandSchemaAnalyzer.cs create mode 100644 CliFx.Analyzers/ConsoleUsageAnalyzer.cs create mode 100644 CliFx.Analyzers/DiagnosticDescriptors.cs create mode 100644 CliFx.Analyzers/Internal/RoslynExtensions.cs create mode 100644 CliFx.Analyzers/KnownSymbols.cs create mode 100644 CliFx/Internal/StringExtensions.cs rename CliFx/Internal/{Extensions.cs => TypeExtensions.cs} (78%) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 02b4aef..38f99b5 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -19,7 +19,11 @@ jobs: dotnet-version: 3.1.100 - name: Pack - run: dotnet pack CliFx --configuration Release + run: | + dotnet pack CliFx.Analyzers --configuration Release + dotnet pack CliFx --configuration Release - name: Deploy - run: dotnet nuget push CliFx/bin/Release/*.nupkg -s nuget.org -k ${{secrets.NUGET_TOKEN}} + run: | + dotnet nuget push CliFx.Analyzers/bin/Release/*.nupkg -s nuget.org -k ${{ secrets.NUGET_TOKEN }} + 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 92c2f62..7e7d3a0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -27,3 +27,9 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} file: CliFx.Tests/bin/Release/Coverage.xml + + - name: Upload coverage (analyzers) + uses: codecov/codecov-action@v1.0.5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: CliFx.Analyzers.Tests/bin/Release/Coverage.xml diff --git a/CliFx.Analyzers.Tests/AnalyzerTestCase.cs b/CliFx.Analyzers.Tests/AnalyzerTestCase.cs new file mode 100644 index 0000000..fa15385 --- /dev/null +++ b/CliFx.Analyzers.Tests/AnalyzerTestCase.cs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..b527192 --- /dev/null +++ b/CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj @@ -0,0 +1,29 @@ + + + + + netcoreapp3.1 + false + true + true + opencover + bin/$(Configuration)/Coverage.xml + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs b/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs new file mode 100644 index 0000000..d0ce6dc --- /dev/null +++ b/CliFx.Analyzers.Tests/CommandSchemaAnalyzerTests.cs @@ -0,0 +1,489 @@ +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( + "Option with a proper name", + Analyzer.SupportedDiagnostics, + + // language=cs + @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption(""foo"")] + public string Param { 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 Param { 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 ParamA { get; set; } + + [CommandOption(""bar"")] + public string ParamB { 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 ParamA { get; set; } + + [CommandOption('x')] + public string ParamB { 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 ParamA { get; set; } + + [CommandOption('b', EnvironmentVariableName = ""env_var_b"")] + public string ParamB { 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( + "Option with an empty name", + DiagnosticDescriptors.CliFx0041, + + // language=cs + @" +[Command] +public class MyCommand : ICommand +{ + [CommandOption("""")] + public string Param { 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 Param { 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 ParamA { get; set; } + + [CommandOption(""foo"")] + public string ParamB { 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 ParamA { get; set; } + + [CommandOption('f')] + public string ParamB { 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 ParamA { get; set; } + + [CommandOption('b', EnvironmentVariableName = ""env_var"")] + public string ParamB { 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 new file mode 100644 index 0000000..6ebc953 --- /dev/null +++ b/CliFx.Analyzers.Tests/ConsoleUsageAnalyzerTests.cs @@ -0,0 +1,144 @@ +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/Internal/AnalyzerAssertions.cs b/CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs new file mode 100644 index 0000000..1eab305 --- /dev/null +++ b/CliFx.Analyzers.Tests/Internal/AnalyzerAssertions.cs @@ -0,0 +1,107 @@ +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 AnalyzerAssertions(analyzer); + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/CliFx.Analyzers.csproj b/CliFx.Analyzers/CliFx.Analyzers.csproj new file mode 100644 index 0000000..782fde2 --- /dev/null +++ b/CliFx.Analyzers/CliFx.Analyzers.csproj @@ -0,0 +1,26 @@ + + + + + netstandard2.0 + $(Company) + Roslyn analyzers for CliFx + https://github.com/Tyrrrz/CliFx + https://github.com/Tyrrrz/CliFx/blob/master/Changelog.md + favicon.png + MIT + True + annotations + + + + + + + + + + + + + \ No newline at end of file diff --git a/CliFx.Analyzers/CommandSchemaAnalyzer.cs b/CliFx.Analyzers/CommandSchemaAnalyzer.cs new file mode 100644 index 0000000..4f9db40 --- /dev/null +++ b/CliFx.Analyzers/CommandSchemaAnalyzer.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CliFx.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.CliFx0041, + DiagnosticDescriptors.CliFx0042, + DiagnosticDescriptors.CliFx0043, + DiagnosticDescriptors.CliFx0044, + DiagnosticDescriptors.CliFx0045 + ); + + 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; + + return new + { + Property = p, + Order = order, + Name = name + }; + }) + .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())); + } + } + + 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; + + return new + { + Property = p, + Name = name, + ShortName = shortName, + EnvironmentVariableName = envVarName + }; + }) + .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.OrdinalIgnoreCase) + .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())); + } + } + + private static void CheckCommandType(SymbolAnalysisContext context) + { + // Named type: MyCommand + if (!(context.Symbol is INamedTypeSymbol namedTypeSymbol)) + return; + + // Only classes + if (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(); + + CheckCommandParameterProperties(context, parameterProperties); + 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 new file mode 100644 index 0000000..cbcf2ce --- /dev/null +++ b/CliFx.Analyzers/ConsoleUsageAnalyzer.cs @@ -0,0 +1,80 @@ +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) + { + // Get the method member access (Console.WriteLine or Console.Error.WriteLine) + if (!(invocationSyntax.Expression is MemberAccessExpressionSyntax memberAccessSyntax)) + return false; + + // Get the semantic model for the invoked method + if (!(context.SemanticModel.GetSymbolInfo(memberAccessSyntax).Symbol is IMethodSymbol methodSymbol)) + return false; + + // Check if contained within System.Console + if (KnownSymbols.IsSystemConsole(methodSymbol.ContainingType)) + return true; + + // In case with Console.Error.WriteLine that wouldn't work, we need to check parent member access too + if (!(memberAccessSyntax.Expression is MemberAccessExpressionSyntax parentMemberAccessSyntax)) + return false; + + // Get the semantic model for the parent member + if (!(context.SemanticModel.GetSymbolInfo(parentMemberAccessSyntax).Symbol is IPropertySymbol propertySymbol)) + return false; + + // Check if contained within System.Console + if (KnownSymbols.IsSystemConsole(propertySymbol.ContainingType)) + return true; + + return false; + } + + private static void CheckSystemConsoleUsage(SyntaxNodeAnalysisContext context) + { + if (!(context.Node is InvocationExpressionSyntax invocationSyntax)) + return; + + if (!IsSystemConsoleInvocation(context, invocationSyntax)) + return; + + // Check if IConsole is available in the scope as a viable alternative + 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) + return; + + 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 new file mode 100644 index 0000000..c056457 --- /dev/null +++ b/CliFx.Analyzers/DiagnosticDescriptors.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; + +namespace CliFx.Analyzers +{ + public static class DiagnosticDescriptors + { + public static readonly DiagnosticDescriptor CliFx0001 = + new DiagnosticDescriptor(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.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0002 = + new DiagnosticDescriptor(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.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0021 = + new DiagnosticDescriptor(nameof(CliFx0021), + "Parameter order must be unique within its command.", + "Parameter order must be unique within its command.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0022 = + new DiagnosticDescriptor(nameof(CliFx0022), + "Parameter order must have unique name within its command.", + "Parameter order must have unique name within its command.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0023 = + new DiagnosticDescriptor(nameof(CliFx0023), + "Only one non-scalar parameter per command is allowed.", + "Only one non-scalar parameter per command is allowed.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0024 = + new DiagnosticDescriptor(nameof(CliFx0024), + "Non-scalar parameter must be last in order.", + "Non-scalar parameter must be last in order.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0041 = + new DiagnosticDescriptor(nameof(CliFx0041), + "Option must have a name or short name specified.", + "Option must have a name or short name specified.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0042 = + new DiagnosticDescriptor(nameof(CliFx0042), + "Option name must be at least 2 characters long.", + "Option name must be at least 2 characters long.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0043 = + new DiagnosticDescriptor(nameof(CliFx0043), + "Option name must be unique within its command.", + "Option name must be unique within its command.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0044 = + new DiagnosticDescriptor(nameof(CliFx0044), + "Option short name must be unique within its command.", + "Option short name must be unique within its command.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0045 = + new DiagnosticDescriptor(nameof(CliFx0045), + "Option environment variable name must be unique within its command.", + "Option environment variable name must be unique within its command.", + "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor CliFx0100 = + new DiagnosticDescriptor(nameof(CliFx0100), + "Avoid using System.Console in commands.", + "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/Internal/RoslynExtensions.cs b/CliFx.Analyzers/Internal/RoslynExtensions.cs new file mode 100644 index 0000000..cc4e46c --- /dev/null +++ b/CliFx.Analyzers/Internal/RoslynExtensions.cs @@ -0,0 +1,11 @@ +using System; +using Microsoft.CodeAnalysis; + +namespace CliFx.Analyzers.Internal +{ + internal static class RoslynExtensions + { + public static bool DisplayNameMatches(this ISymbol symbol, string name) => + string.Equals(symbol.ToDisplayString(), name, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/CliFx.Analyzers/KnownSymbols.cs b/CliFx.Analyzers/KnownSymbols.cs new file mode 100644 index 0000000..88e1aa6 --- /dev/null +++ b/CliFx.Analyzers/KnownSymbols.cs @@ -0,0 +1,37 @@ +using CliFx.Analyzers.Internal; +using Microsoft.CodeAnalysis; + +namespace CliFx.Analyzers +{ + public 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 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.Demo/CliFx.Demo.csproj b/CliFx.Demo/CliFx.Demo.csproj index f61a92b..6eb9532 100644 --- a/CliFx.Demo/CliFx.Demo.csproj +++ b/CliFx.Demo/CliFx.Demo.csproj @@ -7,12 +7,13 @@ - + + \ No newline at end of file diff --git a/CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj b/CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj index dfd8a43..854f574 100644 --- a/CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj +++ b/CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj @@ -8,6 +8,7 @@ + \ No newline at end of file diff --git a/CliFx.Tests/CliFx.Tests.csproj b/CliFx.Tests/CliFx.Tests.csproj index 2398f71..0cec929 100644 --- a/CliFx.Tests/CliFx.Tests.csproj +++ b/CliFx.Tests/CliFx.Tests.csproj @@ -19,9 +19,9 @@ - + diff --git a/CliFx.sln b/CliFx.sln index 3ec063e..4072fad 100644 --- a/CliFx.sln +++ b/CliFx.sln @@ -10,16 +10,20 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}" ProjectSection(SolutionItems) = preProject Changelog.md = Changelog.md + CliFx.props = CliFx.props License.txt = License.txt Readme.md = Readme.md - CliFx.props = CliFx.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Benchmarks", "CliFx.Benchmarks\CliFx.Benchmarks.csproj", "{8ACD6DC2-D768-4850-9223-5B7C83A78513}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Demo", "CliFx.Demo\CliFx.Demo.csproj", "{AAB6844C-BF71-448F-A11B-89AEE459AB15}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{F717347D-8656-44DA-A4A2-BE515E8C4655}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{F717347D-8656-44DA-A4A2-BE515E8C4655}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Analyzers", "CliFx.Analyzers\CliFx.Analyzers.csproj", "{F8460D69-F8CF-405C-A6ED-BED02A21DB42}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Analyzers.Tests", "CliFx.Analyzers.Tests\CliFx.Analyzers.Tests.csproj", "{49878E75-2097-4C79-9151-B98A28FBB973}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -91,6 +95,30 @@ Global {F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x64.Build.0 = Release|Any CPU {F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x86.ActiveCfg = Release|Any CPU {F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x86.Build.0 = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|x64.ActiveCfg = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|x64.Build.0 = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|x86.ActiveCfg = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Debug|x86.Build.0 = Debug|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|Any CPU.Build.0 = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x64.ActiveCfg = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x64.Build.0 = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.ActiveCfg = Release|Any CPU + {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.Build.0 = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x64.ActiveCfg = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x64.Build.0 = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x86.ActiveCfg = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x86.Build.0 = Debug|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|Any CPU.Build.0 = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|x64.ActiveCfg = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|x64.Build.0 = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|x86.ActiveCfg = Release|Any CPU + {49878E75-2097-4C79-9151-B98A28FBB973}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CliFx/CliFx.csproj b/CliFx/CliFx.csproj index 641cd57..4673f34 100644 --- a/CliFx/CliFx.csproj +++ b/CliFx/CliFx.csproj @@ -2,7 +2,7 @@ - netstandard2.1;netstandard2.0;net45 + netstandard2.1;netstandard2.0 $(Company) Declarative framework for CLI applications command line executable interface framework parser arguments net core @@ -10,7 +10,6 @@ https://github.com/Tyrrrz/CliFx/blob/master/Changelog.md favicon.png MIT - True True True True @@ -19,10 +18,28 @@ - + annotations + + + + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).Tests @@ -32,18 +49,4 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CliFx/Internal/Polyfills.cs b/CliFx/Internal/Polyfills.cs index 9070038..e1d5c25 100644 --- a/CliFx/Internal/Polyfills.cs +++ b/CliFx/Internal/Polyfills.cs @@ -2,7 +2,7 @@ // Polyfills to bridge the missing APIs in older versions of the framework/standard. -#if NETSTANDARD2_0 || NET45 +#if NETSTANDARD2_0 namespace System.Collections.Generic { internal static class Extensions diff --git a/CliFx/Internal/StringExtensions.cs b/CliFx/Internal/StringExtensions.cs new file mode 100644 index 0000000..2e4406f --- /dev/null +++ b/CliFx/Internal/StringExtensions.cs @@ -0,0 +1,14 @@ +using System.Text; + +namespace CliFx.Internal +{ + internal static class StringExtensions + { + public static string Repeat(this char c, int count) => new string(c, count); + + public static string AsString(this char c) => c.Repeat(1); + + public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => + builder.Length > 0 ? builder.Append(value) : builder; + } +} \ No newline at end of file diff --git a/CliFx/Internal/Extensions.cs b/CliFx/Internal/TypeExtensions.cs similarity index 78% rename from CliFx/Internal/Extensions.cs rename to CliFx/Internal/TypeExtensions.cs index 8008611..2d05445 100644 --- a/CliFx/Internal/Extensions.cs +++ b/CliFx/Internal/TypeExtensions.cs @@ -2,19 +2,11 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Text; namespace CliFx.Internal { - internal static class Extensions + internal static class TypeExtensions { - public static string Repeat(this char c, int count) => new string(c, count); - - public static string AsString(this char c) => c.Repeat(1); - - public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => - builder.Length > 0 ? builder.Append(value) : builder; - public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType); public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type); diff --git a/Readme.md b/Readme.md index d877a85..ec94a65 100644 --- a/Readme.md +++ b/Readme.md @@ -26,6 +26,7 @@ An important property of CliFx, when compared to some other libraries, is that i - Prints errors and routes exit codes on exceptions - Provides comprehensive and colorful auto-generated help text - Highly testable and easy to debug +- Comes with built-in analyzers to help catch common mistakes - Targets .NET Framework 4.5+ and .NET Standard 2.0+ - No external dependencies