-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #843 from polyadic/functional-assert-analyzer
- Loading branch information
Showing
22 changed files
with
373 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
Funcky.Analyzers/Funcky.Analyzers.CodeFixes/DiagnosticExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Microsoft.CodeAnalysis; | ||
|
||
namespace Funcky.Analyzers; | ||
|
||
internal static class DiagnosticExtensions | ||
{ | ||
// No static interfaces or IParsable<T> in .NET Standard 2.0... | ||
private delegate bool TryParseDelegate<T>(string? s, [NotNullWhen(true)] out T? value); | ||
|
||
public static bool TryGetIntProperty(this Diagnostic diagnostic, string propertyName, out int value) | ||
=> TryGetProperty(diagnostic, propertyName, int.TryParse, out value); | ||
|
||
private static bool TryGetProperty<T>(this Diagnostic diagnostic, string propertyName, TryParseDelegate<T> parser, [NotNullWhen(true)] out T? value) | ||
{ | ||
value = default; | ||
return diagnostic.Properties.TryGetValue(propertyName, out var stringValue) && parser(stringValue, out value); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
Funcky.Analyzers/Funcky.Analyzers.CodeFixes/FunctionalAssertFix.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
using System.Collections.Immutable; | ||
using System.Composition; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.CodeActions; | ||
using Microsoft.CodeAnalysis.CodeFixes; | ||
using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
using static Funcky.Analyzers.FunctionalAssert.FunctionalAssertAnalyzer; | ||
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; | ||
|
||
namespace Funcky.Analyzers; | ||
|
||
[Shared] | ||
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddArgumentNameCodeFix))] | ||
public sealed class FunctionalAssertFix : CodeFixProvider | ||
{ | ||
public override ImmutableArray<string> FixableDiagnosticIds | ||
=> ImmutableArray.Create(PreferFunctionalAssert.Id); | ||
|
||
public override FixAllProvider GetFixAllProvider() | ||
=> WellKnownFixAllProviders.BatchFixer; | ||
|
||
public override async Task RegisterCodeFixesAsync(CodeFixContext context) | ||
{ | ||
if (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false) is { } root | ||
&& await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false) is { } semanticModel) | ||
{ | ||
foreach (var diagnostic in context.Diagnostics) | ||
{ | ||
if (diagnostic.TryGetIntProperty(ExpectedArgumentIndex, out var expectedArgumentIndex) | ||
&& diagnostic.TryGetIntProperty(ActualArgumentIndex, out var actualArgumentIndex) | ||
&& root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf<InvocationExpressionSyntax>() is { } invocationSyntax | ||
&& invocationSyntax.ArgumentList.Arguments[expectedArgumentIndex] is var expectedArgumentSyntax | ||
&& invocationSyntax.ArgumentList.Arguments[actualArgumentIndex] is var actualArgumentSyntax | ||
&& actualArgumentSyntax.Expression is InvocationExpressionSyntax innerInvocationSyntax) | ||
{ | ||
context.RegisterCodeFix(CreateFix(context, new FixInputs(invocationSyntax, expectedArgumentSyntax, actualArgumentSyntax, innerInvocationSyntax)), diagnostic); | ||
} | ||
} | ||
} | ||
} | ||
|
||
private static CodeAction CreateFix(CodeFixContext context, FixInputs inputs) | ||
=> CodeAction.Create( | ||
title: "Simplify assertion", | ||
SimplifyAssertionAsync(context.Document, inputs), | ||
nameof(AddArgumentNameCodeFix)); | ||
|
||
private static Func<CancellationToken, Task<Document>> SimplifyAssertionAsync(Document document, FixInputs inputs) | ||
=> async cancellationToken | ||
=> await document.GetSyntaxRootAsync(cancellationToken) is { } syntaxRoot | ||
? document.WithSyntaxRoot(syntaxRoot.ReplaceNode(inputs.InvocationSyntax, SimplifyAssertion(inputs))) | ||
: document; | ||
|
||
private static InvocationExpressionSyntax SimplifyAssertion(FixInputs inputs) | ||
=> InvocationExpression(inputs.InnerInvocationSyntax.Expression) | ||
.WithArgumentList(FunctionalAssertArgumentList(inputs)) | ||
.WithTriviaFrom(inputs.InvocationSyntax); | ||
|
||
private static ArgumentListSyntax FunctionalAssertArgumentList(FixInputs inputs) | ||
{ | ||
var assertArgumentList = inputs.InvocationSyntax.ArgumentList; | ||
return assertArgumentList.WithArguments(SeparatedList(FunctionalAssertArguments(inputs), assertArgumentList.Arguments.GetSeparators())); | ||
} | ||
|
||
private static IEnumerable<ArgumentSyntax> FunctionalAssertArguments(FixInputs inputs) | ||
=> [ | ||
Argument(inputs.ExpectedArgumentSyntax.Expression).WithTriviaFrom(inputs.ExpectedArgumentSyntax), | ||
Argument(inputs.InnerInvocationSyntax.ArgumentList.Arguments[0].Expression).WithTriviaFrom(inputs.ActualArgumentSyntax), | ||
]; | ||
|
||
private sealed record FixInputs( | ||
InvocationExpressionSyntax InvocationSyntax, | ||
ArgumentSyntax ExpectedArgumentSyntax, | ||
ArgumentSyntax ActualArgumentSyntax, | ||
InvocationExpressionSyntax InnerInvocationSyntax); | ||
} |
45 changes: 45 additions & 0 deletions
45
Funcky.Analyzers/Funcky.Analyzers.Test/FunctionalAssertAnalyzerTest.Setup.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
namespace Funcky.Analyzers.Test; | ||
|
||
public sealed partial class FunctionalAssertAnalyzerTest | ||
{ | ||
// language=csharp | ||
private const string AttributeSource = | ||
""" | ||
namespace Funcky.CodeAnalysis | ||
{ | ||
internal sealed class AssertMethodHasOverloadWithExpectedValueAttribute : System.Attribute { } | ||
} | ||
"""; | ||
|
||
// language=csharp | ||
private const string Stubs = | ||
""" | ||
namespace Xunit | ||
{ | ||
public static class Assert | ||
{ | ||
public static void Equal<T>(T expected, T actual) { } | ||
public static void Equal<T>(T expected, T actual, System.Func<T, T, bool> compare) { } | ||
public static void Equal(System.DateTime expected, System.DateTime actual) { } | ||
} | ||
} | ||
namespace Funcky.Monads | ||
{ | ||
public readonly struct Option<T> { } | ||
} | ||
namespace Funcky | ||
{ | ||
public static class FunctionalAssert | ||
{ | ||
[Funcky.CodeAnalysis.AssertMethodHasOverloadWithExpectedValueAttribute] | ||
public static T Some<T>(Option<T> option) => throw null!; | ||
public static T Some<T>(T expected, Option<T> option) => throw null!; | ||
} | ||
} | ||
"""; | ||
} |
90 changes: 90 additions & 0 deletions
90
Funcky.Analyzers/Funcky.Analyzers.Test/FunctionalAssertAnalyzerTest.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
using Microsoft.CodeAnalysis.Testing; | ||
using Xunit; | ||
using VerifyCS = Funcky.Analyzers.Test.CSharpCodeFixVerifier<Funcky.Analyzers.FunctionalAssert.FunctionalAssertAnalyzer, Funcky.Analyzers.FunctionalAssertFix>; | ||
|
||
namespace Funcky.Analyzers.Test; | ||
|
||
public sealed partial class FunctionalAssertAnalyzerTest | ||
{ | ||
[Fact] | ||
public async Task WarnsWhenCombiningAssertEqualWithOurMethod() | ||
{ | ||
// language=csharp | ||
const string inputCode = | ||
""" | ||
using Funcky; | ||
using Funcky.Monads; | ||
using Xunit; | ||
class C | ||
{ | ||
private void M() | ||
{ | ||
Assert.Equal(42, FunctionalAssert.Some(default(Option<int>))); | ||
Assert.Equal( | ||
42, | ||
FunctionalAssert.Some(default(Option<int>))); | ||
Assert.Equal(42, FunctionalAssert.Some( | ||
default(Option<int>) | ||
)); | ||
Assert.Equal(actual: FunctionalAssert.Some(default(Option<int>)), expected: 42); | ||
} | ||
} | ||
"""; | ||
|
||
// language=csharp | ||
const string fixedCode = | ||
""" | ||
using Funcky; | ||
using Funcky.Monads; | ||
using Xunit; | ||
class C | ||
{ | ||
private void M() | ||
{ | ||
FunctionalAssert.Some(42, default(Option<int>)); | ||
FunctionalAssert.Some( | ||
42, | ||
default(Option<int>)); | ||
FunctionalAssert.Some(42, default(Option<int>)); | ||
FunctionalAssert.Some(42, default(Option<int>)); | ||
} | ||
} | ||
"""; | ||
DiagnosticResult[] expectedDiagnostics = [ | ||
VerifyCS.Diagnostic().WithSpan(9, 9, 9, 70).WithArguments("FunctionalAssert", "Some"), | ||
VerifyCS.Diagnostic().WithSpan(10, 9, 12, 57).WithArguments("FunctionalAssert", "Some"), | ||
VerifyCS.Diagnostic().WithSpan(13, 9, 15, 11).WithArguments("FunctionalAssert", "Some"), | ||
VerifyCS.Diagnostic().WithSpan(16, 9, 16, 88).WithArguments("FunctionalAssert", "Some"), | ||
]; | ||
|
||
await VerifyCS.VerifyCodeFixAsync( | ||
inputCode + Environment.NewLine + AttributeSource + Stubs, | ||
expectedDiagnostics, | ||
fixedCode + Environment.NewLine + AttributeSource + Stubs); | ||
} | ||
|
||
[Fact] | ||
public async Task DoesNotWarnForSpecializedOverloadsOfAssertEqual() | ||
{ | ||
// language=csharp | ||
const string inputCode = | ||
""" | ||
using System; | ||
using Funcky; | ||
using Funcky.Monads; | ||
using Xunit; | ||
class C | ||
{ | ||
private void M() | ||
{ | ||
Assert.Equal(DateTime.UnixEpoch, FunctionalAssert.Some(default(Option<DateTime>))); | ||
Assert.Equal(42, FunctionalAssert.Some(default(Option<int>)), (a, b) => throw null!); | ||
} | ||
} | ||
"""; | ||
await VerifyCS.VerifyAnalyzerAsync(inputCode + AttributeSource + Stubs); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
Funcky.Analyzers/Funcky.Analyzers/FunctionalAssert/FunctionalAssertAnalyzer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
using System.Collections.Immutable; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.Diagnostics; | ||
using Microsoft.CodeAnalysis.Operations; | ||
using static Funcky.Analyzers.FunctionalAssert.FunctionalAssertMatching; | ||
using static Funcky.Analyzers.FunctionalAssert.XunitAssertMatching; | ||
|
||
namespace Funcky.Analyzers.FunctionalAssert; | ||
|
||
[DiagnosticAnalyzer(LanguageNames.CSharp)] | ||
public sealed class FunctionalAssertAnalyzer : DiagnosticAnalyzer | ||
{ | ||
public const string ExpectedArgumentIndex = nameof(ExpectedArgumentIndex); | ||
public const string ActualArgumentIndex = nameof(ActualArgumentIndex); | ||
|
||
public static readonly DiagnosticDescriptor PreferFunctionalAssert = new( | ||
id: $"{DiagnosticName.Prefix}{DiagnosticName.FunctionalAssertUsage}01", | ||
title: "Assert can be simplified", | ||
messageFormat: "Assert can be simplified to a single call to {0}.{1}", | ||
category: nameof(Funcky), | ||
DiagnosticSeverity.Warning, | ||
isEnabledByDefault: true); | ||
|
||
private const string AttributeFullName = "Funcky.CodeAnalysis.AssertMethodHasOverloadWithExpectedValueAttribute"; | ||
|
||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(PreferFunctionalAssert); | ||
|
||
public override void Initialize(AnalysisContext context) | ||
{ | ||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); | ||
context.EnableConcurrentExecution(); | ||
context.RegisterCompilationStartAction(OnCompilationStart); | ||
} | ||
|
||
private static void OnCompilationStart(CompilationStartAnalysisContext context) | ||
{ | ||
if (context.Compilation.GetTypeByMetadataName(AttributeFullName) is { } attributeType) | ||
{ | ||
context.RegisterOperationAction(AnalyzeInvocation(new AssertMethodHasOverloadWithExpectedValueAttributeType(attributeType)), OperationKind.Invocation); | ||
} | ||
} | ||
|
||
private static Action<OperationAnalysisContext> AnalyzeInvocation(AssertMethodHasOverloadWithExpectedValueAttributeType attributeType) | ||
=> context => | ||
{ | ||
var invocation = (IInvocationOperation)context.Operation; | ||
if (MatchGenericAssertEqualInvocation(invocation, out var expectedArgument, out var actualArgument) | ||
&& actualArgument.Value is IInvocationOperation innerInvocation | ||
&& IsAssertMethodWithAccompanyingEqualOverload(innerInvocation, attributeType)) | ||
{ | ||
var properties = ImmutableDictionary<string, string?>.Empty | ||
.Add(ActualArgumentIndex, invocation.Arguments.IndexOf(actualArgument).ToString()) | ||
.Add(ExpectedArgumentIndex, invocation.Arguments.IndexOf(expectedArgument).ToString()); | ||
context.ReportDiagnostic(Diagnostic.Create( | ||
PreferFunctionalAssert, | ||
invocation.Syntax.GetLocation(), | ||
properties, | ||
messageArgs: [innerInvocation.TargetMethod.ContainingType.Name, innerInvocation.TargetMethod.Name])); | ||
} | ||
}; | ||
} |
18 changes: 18 additions & 0 deletions
18
Funcky.Analyzers/Funcky.Analyzers/FunctionalAssert/FunctionalAssertMatching.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.Operations; | ||
|
||
namespace Funcky.Analyzers.FunctionalAssert; | ||
|
||
public sealed class FunctionalAssertMatching | ||
{ | ||
public static bool IsAssertMethodWithAccompanyingEqualOverload( | ||
IInvocationOperation invocation, | ||
AssertMethodHasOverloadWithExpectedValueAttributeType attributeType) | ||
=> invocation.TargetMethod.GetAttributes().Any(IsAttribute(attributeType.Value)) | ||
&& invocation.Arguments.Length == 1; | ||
|
||
private static Func<AttributeData, bool> IsAttribute(INamedTypeSymbol attributeType) | ||
=> data => SymbolEqualityComparer.Default.Equals(data.AttributeClass, attributeType); | ||
} | ||
|
||
public sealed record AssertMethodHasOverloadWithExpectedValueAttributeType(INamedTypeSymbol Value); |
Oops, something went wrong.