Fix converter analyzer false positive when handling non-scalars or nullable types

This commit is contained in:
Oleksii Holub
2022-04-20 20:09:14 +03:00
parent 7f206a0c77
commit 864efd3179
14 changed files with 205 additions and 75 deletions

View File

@@ -9,7 +9,7 @@ public class OptionMustHaveValidConverterAnalyzerSpecs
private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidConverterAnalyzer(); private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidConverterAnalyzer();
[Fact] [Fact]
public void Analyzer_reports_an_error_if_the_specified_option_converter_does_not_derive_from_BindingConverter() public void Analyzer_reports_an_error_if_an_option_has_a_converter_that_does_not_derive_from_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -33,7 +33,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_reports_an_error_if_the_specified_option_converter_does_not_derive_from_a_compatible_BindingConverter() public void Analyzer_reports_an_error_if_an_option_has_a_converter_that_does_not_derive_from_a_compatible_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -57,7 +57,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_the_specified_option_converter_derives_from_a_compatible_BindingConverter() public void Analyzer_does_not_report_an_error_if_an_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -80,6 +80,54 @@ public class MyCommand : ICommand
Analyzer.Should().NotProduceDiagnostics(code); Analyzer.Should().NotProduceDiagnostics(code);
} }
[Fact]
public void Analyzer_does_not_report_an_error_if_a_nullable_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// language=cs
const string code = @"
public class MyConverter : BindingConverter<int>
{
public override int Convert(string rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption(""foo"", Converter = typeof(MyConverter))]
public int? 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_non_scalar_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// language=cs
const string code = @"
public class MyConverter : BindingConverter<string>
{
public override string Convert(string rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption(""foo"", Converter = typeof(MyConverter))]
public IReadOnlyList<string> Foo { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_converter() public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_converter()
{ {

View File

@@ -9,7 +9,7 @@ public class OptionMustHaveValidValidatorsAnalyzerSpecs
private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidValidatorsAnalyzer(); private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidValidatorsAnalyzer();
[Fact] [Fact]
public void Analyzer_reports_an_error_if_one_of_the_specified_option_validators_does_not_derive_from_BindingValidator() public void Analyzer_reports_an_error_if_an_option_has_a_validator_that_does_not_derive_from_BindingValidator()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -33,7 +33,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_reports_an_error_if_one_of_the_specified_option_validators_does_not_derive_from_a_compatible_BindingValidator() public void Analyzer_reports_an_error_if_an_option_has_a_validator_that_does_not_derive_from_a_compatible_BindingValidator()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -57,7 +57,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_each_specified_option_validator_derives_from_a_compatible_BindingValidator() public void Analyzer_does_not_report_an_error_if_an_option_has_validators_that_all_derive_from_compatible_BindingValidators()
{ {
// Arrange // Arrange
// language=cs // language=cs

View File

@@ -9,7 +9,7 @@ public class ParameterMustHaveValidConverterAnalyzerSpecs
private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidConverterAnalyzer(); private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidConverterAnalyzer();
[Fact] [Fact]
public void Analyzer_reports_an_error_if_the_specified_parameter_converter_does_not_derive_from_BindingConverter() public void Analyzer_reports_an_error_if_a_parameter_has_a_converter_that_does_not_derive_from_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -33,7 +33,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_reports_an_error_if_the_specified_parameter_converter_does_not_derive_from_a_compatible_BindingConverter() public void Analyzer_reports_an_error_if_a_parameter_has_a_converter_that_does_not_derive_from_a_compatible_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -58,7 +58,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_the_specified_parameter_converter_derives_from_a_compatible_BindingConverter() public void Analyzer_does_not_report_an_error_if_a_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -81,6 +81,54 @@ public class MyCommand : ICommand
Analyzer.Should().NotProduceDiagnostics(code); Analyzer.Should().NotProduceDiagnostics(code);
} }
[Fact]
public void Analyzer_does_not_report_an_error_if_a_nullable_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// language=cs
const string code = @"
public class MyConverter : BindingConverter<int>
{
public override int Convert(string rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption(""foo"", Converter = typeof(MyConverter))]
public int? 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_non_scalar_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// language=cs
const string code = @"
public class MyConverter : BindingConverter<string>
{
public override string Convert(string rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public IReadOnlyList<string> Foo { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_a_converter() public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_a_converter()
{ {

View File

@@ -9,7 +9,7 @@ public class ParameterMustHaveValidValidatorsAnalyzerSpecs
private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidValidatorsAnalyzer(); private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveValidValidatorsAnalyzer();
[Fact] [Fact]
public void Analyzer_reports_an_error_if_one_of_the_specified_parameter_validators_does_not_derive_from_BindingValidator() public void Analyzer_reports_an_error_a_parameter_has_a_validator_that_does_not_derive_from_BindingValidator()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -33,7 +33,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_reports_an_error_if_one_of_the_specified_parameter_validators_does_not_derive_from_a_compatible_BindingValidator() public void Analyzer_reports_an_error_if_a_parameter_has_a_validator_that_does_not_derive_from_a_compatible_BindingValidator()
{ {
// Arrange // Arrange
// language=cs // language=cs
@@ -57,7 +57,7 @@ public class MyCommand : ICommand
} }
[Fact] [Fact]
public void Analyzer_does_not_report_an_error_if_each_specified_parameter_validator_derives_from_a_compatible_BindingValidator() public void Analyzer_does_not_report_an_error_if_a_parameter_has_validators_that_all_derive_from_compatible_BindingValidators()
{ {
// Arrange // Arrange
// language=cs // language=cs

View File

@@ -5,8 +5,10 @@ using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel; namespace CliFx.Analyzers.ObjectModel;
internal partial class CommandOptionSymbol internal partial class CommandOptionSymbol : ICommandMemberSymbol
{ {
public IPropertySymbol Property { get; }
public string? Name { get; } public string? Name { get; }
public char? ShortName { get; } public char? ShortName { get; }
@@ -16,11 +18,13 @@ internal partial class CommandOptionSymbol
public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; } public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; }
public CommandOptionSymbol( public CommandOptionSymbol(
IPropertySymbol property,
string? name, string? name,
char? shortName, char? shortName,
ITypeSymbol? converterType, ITypeSymbol? converterType,
IReadOnlyList<ITypeSymbol> validatorTypes) IReadOnlyList<ITypeSymbol> validatorTypes)
{ {
Property = property;
Name = name; Name = name;
ShortName = shortName; ShortName = shortName;
ConverterType = converterType; ConverterType = converterType;
@@ -30,22 +34,25 @@ internal partial class CommandOptionSymbol
internal partial class CommandOptionSymbol internal partial class CommandOptionSymbol
{ {
private static AttributeData? TryGetOptionAttribute(IPropertySymbol property) => private static AttributeData? TryGetOptionAttribute(IPropertySymbol property) => property
property .GetAttributes()
.GetAttributes() .FirstOrDefault(a => a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute) == true);
.FirstOrDefault(a => a.AttributeClass.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute));
private static CommandOptionSymbol FromAttribute(AttributeData attribute) public static CommandOptionSymbol? TryResolve(IPropertySymbol property)
{ {
var attribute = TryGetOptionAttribute(property);
if (attribute is null)
return null;
var name = attribute var name = attribute
.ConstructorArguments .ConstructorArguments
.Where(a => a.Type.DisplayNameMatches("string") || a.Type.DisplayNameMatches("System.String")) .Where(a => a.Type?.SpecialType == SpecialType.System_String)
.Select(a => a.Value) .Select(a => a.Value)
.FirstOrDefault() as string; .FirstOrDefault() as string;
var shortName = attribute var shortName = attribute
.ConstructorArguments .ConstructorArguments
.Where(a => a.Type.DisplayNameMatches("char") || a.Type.DisplayNameMatches("System.Char")) .Where(a => a.Type?.SpecialType == SpecialType.System_Char)
.Select(a => a.Value) .Select(a => a.Value)
.FirstOrDefault() as char?; .FirstOrDefault() as char?;
@@ -64,16 +71,7 @@ internal partial class CommandOptionSymbol
.Cast<ITypeSymbol>() .Cast<ITypeSymbol>()
.ToArray(); .ToArray();
return new CommandOptionSymbol(name, shortName, converter, validators); return new CommandOptionSymbol(property, name, shortName, converter, validators);
}
public static CommandOptionSymbol? TryResolve(IPropertySymbol property)
{
var attribute = TryGetOptionAttribute(property);
return attribute is not null
? FromAttribute(attribute)
: null;
} }
public static bool IsOptionProperty(IPropertySymbol property) => public static bool IsOptionProperty(IPropertySymbol property) =>

View File

@@ -5,8 +5,10 @@ using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel; namespace CliFx.Analyzers.ObjectModel;
internal partial class CommandParameterSymbol internal partial class CommandParameterSymbol : ICommandMemberSymbol
{ {
public IPropertySymbol Property { get; }
public int Order { get; } public int Order { get; }
public string? Name { get; } public string? Name { get; }
@@ -18,12 +20,14 @@ internal partial class CommandParameterSymbol
public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; } public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; }
public CommandParameterSymbol( public CommandParameterSymbol(
IPropertySymbol property,
int order, int order,
string? name, string? name,
bool? isRequired, bool? isRequired,
ITypeSymbol? converterType, ITypeSymbol? converterType,
IReadOnlyList<ITypeSymbol> validatorTypes) IReadOnlyList<ITypeSymbol> validatorTypes)
{ {
Property = property;
Order = order; Order = order;
Name = name; Name = name;
IsRequired = isRequired; IsRequired = isRequired;
@@ -34,13 +38,16 @@ internal partial class CommandParameterSymbol
internal partial class CommandParameterSymbol internal partial class CommandParameterSymbol
{ {
private static AttributeData? TryGetParameterAttribute(IPropertySymbol property) => private static AttributeData? TryGetParameterAttribute(IPropertySymbol property) => property
property .GetAttributes()
.GetAttributes() .FirstOrDefault(a => a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute) == true);
.FirstOrDefault(a => a.AttributeClass.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute));
private static CommandParameterSymbol FromAttribute(AttributeData attribute) public static CommandParameterSymbol? TryResolve(IPropertySymbol property)
{ {
var attribute = TryGetParameterAttribute(property);
if (attribute is null)
return null;
var order = (int)attribute var order = (int)attribute
.ConstructorArguments .ConstructorArguments
.Select(a => a.Value) .Select(a => a.Value)
@@ -73,16 +80,7 @@ internal partial class CommandParameterSymbol
.Cast<ITypeSymbol>() .Cast<ITypeSymbol>()
.ToArray(); .ToArray();
return new CommandParameterSymbol(order, name, isRequired, converter, validators); return new CommandParameterSymbol(property, order, name, isRequired, converter, validators);
}
public static CommandParameterSymbol? TryResolve(IPropertySymbol property)
{
var attribute = TryGetParameterAttribute(property);
return attribute is not null
? FromAttribute(attribute)
: null;
} }
public static bool IsParameterProperty(IPropertySymbol property) => public static bool IsParameterProperty(IPropertySymbol property) =>

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel;
internal interface ICommandMemberSymbol
{
IPropertySymbol Property { get; }
ITypeSymbol? ConverterType { get; }
IReadOnlyList<ITypeSymbol> ValidatorTypes { get; }
}
internal static class CommandMemberSymbolExtensions
{
public static bool IsScalar(this ICommandMemberSymbol member) =>
member.Property.Type.SpecialType == SpecialType.System_String ||
member.Property.Type.TryGetEnumerableUnderlyingType() is null;
}

View File

@@ -37,7 +37,16 @@ public class OptionMustHaveValidConverterAnalyzer : AnalyzerBase
.FirstOrDefault(); .FirstOrDefault();
// Value returned by the converter must be assignable to the property type // Value returned by the converter must be assignable to the property type
if (converterValueType is null || !property.Type.IsAssignableFrom(converterValueType)) var isCompatible =
converterValueType is not null && (option.IsScalar()
// Scalar
? context.Compilation.IsAssignable(converterValueType, property.Type)
// Non-scalar (assume we can handle all IEnumerable types for simplicity)
: property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType &&
context.Compilation.IsAssignable(converterValueType, enumerableUnderlyingType)
);
if (!isCompatible)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())

View File

@@ -35,7 +35,11 @@ public class OptionMustHaveValidValidatorsAnalyzer : AnalyzerBase
.FirstOrDefault(); .FirstOrDefault();
// Value passed to the validator must be assignable from the property type // Value passed to the validator must be assignable from the property type
if (validatorValueType is null || !validatorValueType.IsAssignableFrom(property.Type)) var isCompatible =
validatorValueType is not null &&
context.Compilation.IsAssignable(property.Type, validatorValueType);
if (!isCompatible)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())

View File

@@ -17,13 +17,6 @@ public class ParameterMustBeLastIfNonScalarAnalyzer : AnalyzerBase
{ {
} }
private static bool IsScalar(ITypeSymbol type) =>
type.DisplayNameMatches("string") ||
type.DisplayNameMatches("System.String") ||
!type.AllInterfaces
.Select(i => i.ConstructedFrom)
.Any(t => t.DisplayNameMatches("System.Collections.Generic.IEnumerable<T>"));
private void Analyze( private void Analyze(
SyntaxNodeAnalysisContext context, SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration, PropertyDeclarationSyntax propertyDeclaration,
@@ -32,13 +25,13 @@ public class ParameterMustBeLastIfNonScalarAnalyzer : AnalyzerBase
if (property.ContainingType is null) if (property.ContainingType is null)
return; return;
if (IsScalar(property.Type))
return;
var parameter = CommandParameterSymbol.TryResolve(property); var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null) if (parameter is null)
return; return;
if (parameter.IsScalar())
return;
var otherProperties = property var otherProperties = property
.ContainingType .ContainingType
.GetMembers() .GetMembers()

View File

@@ -17,13 +17,6 @@ public class ParameterMustBeSingleIfNonScalarAnalyzer : AnalyzerBase
{ {
} }
private static bool IsScalar(ITypeSymbol type) =>
type.DisplayNameMatches("string") ||
type.DisplayNameMatches("System.String") ||
!type.AllInterfaces
.Select(i => i.ConstructedFrom)
.Any(t => t.DisplayNameMatches("System.Collections.Generic.IEnumerable<T>"));
private void Analyze( private void Analyze(
SyntaxNodeAnalysisContext context, SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration, PropertyDeclarationSyntax propertyDeclaration,
@@ -32,10 +25,11 @@ public class ParameterMustBeSingleIfNonScalarAnalyzer : AnalyzerBase
if (property.ContainingType is null) if (property.ContainingType is null)
return; return;
if (!CommandParameterSymbol.IsParameterProperty(property)) var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return; return;
if (IsScalar(property.Type)) if (parameter.IsScalar())
return; return;
var otherProperties = property var otherProperties = property
@@ -47,10 +41,11 @@ public class ParameterMustBeSingleIfNonScalarAnalyzer : AnalyzerBase
foreach (var otherProperty in otherProperties) foreach (var otherProperty in otherProperties)
{ {
if (!CommandParameterSymbol.IsParameterProperty(otherProperty)) var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue; continue;
if (!IsScalar(otherProperty.Type)) if (!otherParameter.IsScalar())
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())

View File

@@ -37,7 +37,16 @@ public class ParameterMustHaveValidConverterAnalyzer : AnalyzerBase
.FirstOrDefault(); .FirstOrDefault();
// Value returned by the converter must be assignable to the property type // Value returned by the converter must be assignable to the property type
if (converterValueType is null || !property.Type.IsAssignableFrom(converterValueType)) var isCompatible =
converterValueType is not null && (parameter.IsScalar()
// Scalar
? context.Compilation.IsAssignable(converterValueType, property.Type)
// Non-scalar (assume we can handle all IEnumerable types for simplicity)
: property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType &&
context.Compilation.IsAssignable(converterValueType, enumerableUnderlyingType)
);
if (!isCompatible)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())

View File

@@ -35,7 +35,11 @@ public class ParameterMustHaveValidValidatorsAnalyzer : AnalyzerBase
.FirstOrDefault(); .FirstOrDefault();
// Value passed to the validator must be assignable from the property type // Value passed to the validator must be assignable from the property type
if (validatorValueType is null || !validatorValueType.IsAssignableFrom(property.Type)) var isCompatible =
validatorValueType is not null &&
context.Compilation.IsAssignable(property.Type, validatorValueType);
if (!isCompatible)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())

View File

@@ -29,10 +29,13 @@ internal static class RoslynExtensions
} }
} }
public static bool IsAssignableFrom(this ITypeSymbol target, ITypeSymbol source) => public static ITypeSymbol? TryGetEnumerableUnderlyingType(this ITypeSymbol type) => type
SymbolEqualityComparer.Default.Equals(target, source) || .AllInterfaces
source.GetBaseTypes().Contains(target, SymbolEqualityComparer.Default) || .FirstOrDefault(i => i.ConstructedFrom.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T)?
source.AllInterfaces.Contains(target, SymbolEqualityComparer.Default); .TypeArguments[0];
public static bool IsAssignable(this Compilation compilation, ITypeSymbol source, ITypeSymbol destination) =>
compilation.ClassifyConversion(source, destination).Exists;
public static void HandleClassDeclaration( public static void HandleClassDeclaration(
this AnalysisContext analysisContext, this AnalysisContext analysisContext,