Add CliFx.Analyzers (#50)

This commit is contained in:
Alexey Golub
2020-04-25 18:03:21 +03:00
committed by GitHub
parent a28223fc8b
commit d25873ee10
22 changed files with 1426 additions and 33 deletions

View File

@@ -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<DiagnosticDescriptor> TestedDiagnostics { get; }
public IReadOnlyList<string> SourceCodes { get; }
public AnalyzerTestCase(
string name,
IReadOnlyList<DiagnosticDescriptor> testedDiagnostics,
IReadOnlyList<string> sourceCodes)
{
Name = name;
TestedDiagnostics = testedDiagnostics;
SourceCodes = sourceCodes;
}
public AnalyzerTestCase(
string name,
IReadOnlyList<DiagnosticDescriptor> 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))}]";
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gu.Roslyn.Asserts" Version="3.3.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.4.0" />
<PackageReference Include="coverlet.msbuild" Version="2.8.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<object[]> 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<string> 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<string> 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<object[]> 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<string> ParamA { get; set; }
[CommandParameter(2)]
public HashSet<string> 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<string> 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);
}
}

View File

@@ -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<object[]> 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<object[]> 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);
}
}

View File

@@ -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<DiagnosticAnalyzer, AnalyzerAssertions>
{
protected override string Identifier { get; } = "analyzer";
public AnalyzerAssertions(DiagnosticAnalyzer analyzer)
: base(analyzer)
{
}
public void ProduceDiagnostics(
IReadOnlyList<DiagnosticDescriptor> diagnostics,
IReadOnlyList<string> 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) : "<none>")}
".Trim());
}
public void ProduceDiagnostics(AnalyzerTestCase testCase) =>
ProduceDiagnostics(testCase.TestedDiagnostics, testCase.SourceCodes);
public void NotProduceDiagnostics(
IReadOnlyList<DiagnosticDescriptor> diagnostics,
IReadOnlyList<string> 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<MetadataReference> 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<Diagnostic> GetProducedDiagnostics(
DiagnosticAnalyzer analyzer,
IReadOnlyList<string> 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);
}
}