Skip to content

Commit

Permalink
Merge pull request #843 from polyadic/functional-assert-analyzer
Browse files Browse the repository at this point in the history
  • Loading branch information
bash authored Feb 5, 2025
2 parents 5336e03 + ddc68f7 commit 40b406d
Show file tree
Hide file tree
Showing 22 changed files with 373 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
foreach (var diagnostic in context.Diagnostics)
{
if (syntaxRoot?.FindInvocationExpression(context.Span) is { Expression: MemberAccessExpressionSyntax memberAccessExpression } invocation
&& diagnostic.Properties.TryGetValue(PreservedArgumentIndexProperty, out var errorStateArgumentIndexString)
&& int.TryParse(errorStateArgumentIndexString, out var noneArgumentIndex))
&& diagnostic.TryGetIntProperty(PreservedArgumentIndexProperty, out var noneArgumentIndex))
{
context.RegisterCodeFix(new GetOrElseCodeFixAction(context.Document, invocation, memberAccessExpression, noneArgumentIndex, DiagnosticIdToMethodName(diagnostic.Id)), diagnostic);
}
Expand Down
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
var diagnosticSpan = diagnostic.Location.SourceSpan;

if (root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType<InvocationExpressionSyntax>().First() is { } declaration
&& diagnostic.Properties.TryGetValue(ValueParameterIndexProperty, out var valueParameterIndexProperty)
&& int.TryParse(valueParameterIndexProperty, out var valueParameterIndex))
&& diagnostic.TryGetIntProperty(ValueParameterIndexProperty, out var valueParameterIndex))
{
context.RegisterCodeFix(new ToEnumerableEmptyCodeAction(context.Document, declaration, valueParameterIndex), diagnostic);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
var diagnosticSpan = diagnostic.Location.SourceSpan;

if (root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType<InvocationExpressionSyntax>().First() is { } declaration
&& diagnostic.Properties.TryGetValue(ValueParameterIndexProperty, out var valueParameterIndexProperty)
&& int.TryParse(valueParameterIndexProperty, out var valueParameterIndex))
&& diagnostic.TryGetIntProperty(ValueParameterIndexProperty, out var valueParameterIndex))
{
context.RegisterCodeFix(new ToSequenceReturnCodeAction(context.Document, declaration, valueParameterIndex), diagnostic);
}
Expand Down
76 changes: 76 additions & 0 deletions Funcky.Analyzers/Funcky.Analyzers.CodeFixes/FunctionalAssertFix.cs
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);
}
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!;
}
}
""";
}
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
λ1101 | Funcky | Warning | FunctionalAssertAnalyzer
2 changes: 2 additions & 0 deletions Funcky.Analyzers/Funcky.Analyzers/DiagnosticName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ internal static class DiagnosticName
public const string Prefix = "λ";

public const string Usage = "10";

public const string FunctionalAssertUsage = "11";
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ public static class FunckyWellKnownMemberNames

/// <summary>The <c>ToNullable</c> method on alternative monad types.</summary>
public const string ToNullableMethodName = "ToNullable";

public static class XunitAssert
{
public const string EqualMethodName = "Equal";
}
}
2 changes: 2 additions & 0 deletions Funcky.Analyzers/Funcky.Analyzers/FunckyWellKnownTypeNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ public static class FunckyWellKnownTypeNames
public static INamedTypeSymbol? GetFunctionalType(this Compilation compilation) => compilation.GetTypeByMetadataName("Funcky.Functional");

public static INamedTypeSymbol? GetExpressionOfTType(this Compilation compilation) => compilation.GetTypeByMetadataName("System.Linq.Expressions.Expression`1");

public static INamedTypeSymbol? GetXunitAssertType(this Compilation compilation) => compilation.GetTypeByMetadataName("Xunit.Assert");
}
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]));
}
};
}
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);
Loading

0 comments on commit 40b406d

Please sign in to comment.