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,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Authors>$(Company)</Authors>
<Description>Roslyn analyzers for CliFx</Description>
<PackageProjectUrl>https://github.com/Tyrrrz/CliFx</PackageProjectUrl>
<PackageReleaseNotes>https://github.com/Tyrrrz/CliFx/blob/master/Changelog.md</PackageReleaseNotes>
<PackageIcon>favicon.png</PackageIcon>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PublishRepositoryUrl>True</PublishRepositoryUrl>
<Nullable>annotations</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.4.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="../favicon.png" Pack="True" PackagePath="" />
<None Include="$(OutputPath)/$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>

View File

@@ -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<DiagnosticDescriptor> 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<IPropertySymbol> 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<IPropertySymbol> 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<IPropertySymbol>().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);
}
}
}

View File

@@ -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<DiagnosticDescriptor> 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<MethodDeclarationSyntax>()
.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);
}
}
}

View File

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

View File

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

View File

@@ -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<T>");
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");
}
}