12 Commits
2.2.3 ... 2.2.4

Author SHA1 Message Date
Oleksii Holub
6d33c5cdad Update version 2022-04-22 19:15:44 +03:00
Oleksii Holub
e4c899c6c2 Reduce version of Microsoft.CodeAnalysis.CSharp further 2022-04-22 18:55:47 +03:00
Oleksii Holub
35b3ad0d63 Don't use Fody but also don't bundle Microsoft.CodeAnalysis.CSharp 2022-04-22 18:45:46 +03:00
Oleksii Holub
4e70557b47 Reorganize assets 2022-04-22 17:16:51 +03:00
Oleksii Holub
0a8d58255a Update NuGet.config 2022-04-22 16:58:21 +03:00
Oleksii Holub
d3fbc9c643 Merge analyzer project dependencies using Fody.Costura
Closes #127
2022-04-22 16:41:24 +03:00
Oleksii Holub
1cbf8776be Don't produce color codes in tests 2022-04-21 22:57:14 +03:00
Oleksii Holub
16e33f7b8f Update workflow 2022-04-21 22:51:11 +03:00
Oleksii Holub
5c848056c5 Add more contextual information to diagnostics 2022-04-20 20:27:53 +03:00
Oleksii Holub
864efd3179 Fix converter analyzer false positive when handling non-scalars or nullable types 2022-04-20 20:09:14 +03:00
Oleksii Holub
7f206a0c77 Update test logger 2022-04-19 01:31:40 +00:00
Oleksii Holub
22c15f8ec6 Update test logger 2022-04-18 19:53:52 +00:00
36 changed files with 286 additions and 112 deletions

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -4,7 +4,9 @@ on: [push, pull_request]
jobs: jobs:
main: main:
uses: Tyrrrz/.github/.github/workflows/NuGet.yml@master uses: Tyrrrz/.github/.github/workflows/nuget.yml@master
with:
dotnet-version: 6.0.x
secrets: secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }}

View File

@@ -1,3 +1,9 @@
### v2.2.4 (22-Apr-2022)
- Added more contextual information to analyzer diagnostics.
- Fixed an issue where the analyzer incorrectly reported an error on converters that didn't directly match the target type but were compatible through known built-in conversions.
- Fixed an issue where MSBuild produced a lot of analyzer-related warnings in certain circumstances.
### v2.2.3 (17-Apr-2022) ### v2.2.3 (17-Apr-2022)
- Changed method signature of `IConsole.ReadKey()` to return `ConsoleKeyInfo` instead of `void`. The return type was originally defined as `void` by mistake. This change is source-backwards-compatible but may break on binary level if you were previously calling this method indirectly (i.e. through a library). - Changed method signature of `IConsole.ReadKey()` to return `ConsoleKeyInfo` instead of `void`. The return type was originally defined as `void` by mistake. This change is source-backwards-compatible but may break on binary level if you were previously calling this method indirectly (i.e. through a library).

View File

@@ -10,8 +10,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Basic.Reference.Assemblies" Version="1.2.4" /> <PackageReference Include="Basic.Reference.Assemblies" Version="1.2.4" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.4.1" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.5.1" /> <PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

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

@@ -8,7 +8,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" PrivateAssets="all" /> <!-- Keep this dependency as low as possible since we can't bundle it -->
<!-- https://github.com/Tyrrrz/CliFx/issues/127 -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
</Project> </Project>

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)); .FirstOrDefault(a => a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute) == true);
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)); .FirstOrDefault(a => a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute) == true);
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

@@ -15,7 +15,8 @@ public class OptionMustHaveUniqueNameAnalyzer : AnalyzerBase
: base( : base(
"Options must have unique names", "Options must have unique names",
"This option's name must be unique within the command (comparison IS NOT case sensitive). " + "This option's name must be unique within the command (comparison IS NOT case sensitive). " +
"Specified name: '{0}'.") "Specified name: `{0}`. " +
"Property bound to another option with the same name: `{1}`.")
{ {
} }
@@ -55,7 +56,8 @@ public class OptionMustHaveUniqueNameAnalyzer : AnalyzerBase
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic( CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(), propertyDeclaration.Identifier.GetLocation(),
option.Name option.Name,
otherProperty.Name
) )
); );
} }

View File

@@ -14,7 +14,8 @@ public class OptionMustHaveUniqueShortNameAnalyzer : AnalyzerBase
: base( : base(
"Options must have unique short names", "Options must have unique short names",
"This option's short name must be unique within the command (comparison IS case sensitive). " + "This option's short name must be unique within the command (comparison IS case sensitive). " +
"Specified short name: '{0}'.") "Specified short name: `{0}` " +
"Property bound to another option with the same short name: `{1}`.")
{ {
} }
@@ -54,7 +55,8 @@ public class OptionMustHaveUniqueShortNameAnalyzer : AnalyzerBase
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic( CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(), propertyDeclaration.Identifier.GetLocation(),
option.ShortName option.ShortName,
otherProperty.Name
) )
); );
} }

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

@@ -13,7 +13,7 @@ public class OptionMustHaveValidNameAnalyzer : AnalyzerBase
: base( : base(
"Options must have valid names", "Options must have valid names",
"This option's name must be at least 2 characters long and must start with a letter. " + "This option's name must be at least 2 characters long and must start with a letter. " +
"Specified name: '{0}'.") "Specified name: `{0}`.")
{ {
} }

View File

@@ -13,7 +13,7 @@ public class OptionMustHaveValidShortNameAnalyzer : AnalyzerBase
: base( : base(
"Option short names must be letter characters", "Option short names must be letter characters",
"This option's short name must be a single letter character. " + "This option's short name must be a single letter character. " +
"Specified short name: '{0}'.") "Specified short name: `{0}`.")
{ {
} }

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

@@ -13,7 +13,8 @@ public class ParameterMustBeLastIfNonRequiredAnalyzer : AnalyzerBase
public ParameterMustBeLastIfNonRequiredAnalyzer() public ParameterMustBeLastIfNonRequiredAnalyzer()
: base( : base(
"Parameters marked as non-required must be the last in order", "Parameters marked as non-required must be the last in order",
"This parameter is non-required so it must be the last in order (its order must be highest within the command).") "This parameter is non-required so it must be the last in order (its order must be highest within the command). " +
"Property bound to another non-required parameter: `{0}`.")
{ {
} }
@@ -48,7 +49,10 @@ public class ParameterMustBeLastIfNonRequiredAnalyzer : AnalyzerBase
if (otherParameter.Order > parameter.Order) if (otherParameter.Order > parameter.Order)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
); );
} }
} }

View File

@@ -13,17 +13,11 @@ public class ParameterMustBeLastIfNonScalarAnalyzer : AnalyzerBase
public ParameterMustBeLastIfNonScalarAnalyzer() public ParameterMustBeLastIfNonScalarAnalyzer()
: base( : base(
"Parameters of non-scalar types must be the last in order", "Parameters of non-scalar types must be the last in order",
"This parameter has a non-scalar type so it must be the last in order (its order must be highest within the command).") "This parameter has a non-scalar type so it must be the last in order (its order must be highest within the command). " +
"Property bound to another non-scalar parameter: `{0}`.")
{ {
} }
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 +26,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()
@@ -55,7 +49,10 @@ public class ParameterMustBeLastIfNonScalarAnalyzer : AnalyzerBase
if (otherParameter.Order > parameter.Order) if (otherParameter.Order > parameter.Order)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
); );
} }
} }

View File

@@ -13,7 +13,8 @@ public class ParameterMustBeSingleIfNonRequiredAnalyzer : AnalyzerBase
public ParameterMustBeSingleIfNonRequiredAnalyzer() public ParameterMustBeSingleIfNonRequiredAnalyzer()
: base( : base(
"Parameters marked as non-required are limited to one per command", "Parameters marked as non-required are limited to one per command",
"This parameter is non-required so it must be the only such parameter in the command.") "This parameter is non-required so it must be the only such parameter in the command. " +
"Property bound to another non-required parameter: `{0}`.")
{ {
} }
@@ -48,7 +49,10 @@ public class ParameterMustBeSingleIfNonRequiredAnalyzer : AnalyzerBase
if (otherParameter.IsRequired == false) if (otherParameter.IsRequired == false)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
); );
} }
} }

View File

@@ -13,17 +13,11 @@ public class ParameterMustBeSingleIfNonScalarAnalyzer : AnalyzerBase
public ParameterMustBeSingleIfNonScalarAnalyzer() public ParameterMustBeSingleIfNonScalarAnalyzer()
: base( : base(
"Parameters of non-scalar types are limited to one per command", "Parameters of non-scalar types are limited to one per command",
"This parameter has a non-scalar type so it must be the only such parameter in the command.") "This parameter has a non-scalar type so it must be the only such parameter in the command. " +
"Property bound to another non-scalar parameter: `{0}`.")
{ {
} }
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 +26,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,13 +42,17 @@ 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(),
otherProperty.Name
)
); );
} }
} }

View File

@@ -15,7 +15,8 @@ public class ParameterMustHaveUniqueNameAnalyzer : AnalyzerBase
: base( : base(
"Parameters must have unique names", "Parameters must have unique names",
"This parameter's name must be unique within the command (comparison IS NOT case sensitive). " + "This parameter's name must be unique within the command (comparison IS NOT case sensitive). " +
"Specified name: '{0}'.") "Specified name: `{0}`. " +
"Property bound to another parameter with the same name: `{1}`.")
{ {
} }
@@ -55,7 +56,8 @@ public class ParameterMustHaveUniqueNameAnalyzer : AnalyzerBase
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic( CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(), propertyDeclaration.Identifier.GetLocation(),
parameter.Name parameter.Name,
otherProperty.Name
) )
); );
} }

View File

@@ -14,7 +14,8 @@ public class ParameterMustHaveUniqueOrderAnalyzer : AnalyzerBase
: base( : base(
"Parameters must have unique order", "Parameters must have unique order",
"This parameter's order must be unique within the command. " + "This parameter's order must be unique within the command. " +
"Specified order: {0}.") "Specified order: {0}. " +
"Property bound to another parameter with the same order: `{1}`.")
{ {
} }
@@ -48,7 +49,8 @@ public class ParameterMustHaveUniqueOrderAnalyzer : AnalyzerBase
context.ReportDiagnostic( context.ReportDiagnostic(
CreateDiagnostic( CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(), propertyDeclaration.Identifier.GetLocation(),
parameter.Order parameter.Order,
otherProperty.Name
) )
); );
} }

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,

View File

@@ -12,7 +12,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> <ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> <ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,5 @@
using System.Reflection; using System;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace CliFx.Tests.Dummy; namespace CliFx.Tests.Dummy;
@@ -14,9 +15,17 @@ public static partial class Program
public static partial class Program public static partial class Program
{ {
public static async Task Main() => public static async Task Main()
{
// Make sure color codes are not produced because we rely on the output in tests
Environment.SetEnvironmentVariable(
"DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION",
"false"
);
await new CliApplicationBuilder() await new CliApplicationBuilder()
.AddCommandsFromThisAssembly() .AddCommandsFromThisAssembly()
.Build() .Build()
.RunAsync(); .RunAsync();
} }
}

View File

@@ -10,9 +10,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Basic.Reference.Assemblies" Version="1.2.4" /> <PackageReference Include="Basic.Reference.Assemblies" Version="1.2.4" />
<PackageReference Include="CliWrap" Version="3.4.1" /> <PackageReference Include="CliWrap" Version="3.4.3" />
<PackageReference Include="FluentAssertions" Version="6.5.1" /> <PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.4.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

View File

@@ -33,10 +33,8 @@
<!-- Pack the analyzer assembly inside the package --> <!-- Pack the analyzer assembly inside the package -->
<ItemGroup> <ItemGroup>
<ProjectReference Include="../CliFx.Analyzers/CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> <ProjectReference Include="../CliFx.Analyzers/CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/CliFx.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> <None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/CliFx.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/Microsoft.CodeAnalysis.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/Microsoft.CodeAnalysis.CSharp.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Buffers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> <None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Buffers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Collections.Immutable.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> <None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Collections.Immutable.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Memory.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> <None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Memory.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />

View File

@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.2.3</Version> <Version>2.2.4</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (C) Oleksii Holub</Copyright> <Copyright>Copyright (C) Oleksii Holub</Copyright>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>

View File

@@ -2,6 +2,9 @@
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources> </packageSources>
<config>
<add key="defaultPushSource" value="https://api.nuget.org/v3/index.json" />
</config>
</configuration> </configuration>

View File

@@ -45,7 +45,7 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
## Screenshots ## Screenshots
![help screen](.screenshots/help.png) ![help screen](.assets/help-screen.png)
## Usage ## Usage

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB