Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Funcky.Analyzers.CodeAnalysisExtensions;
using Funcky.Analyzers.Fallback;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
Expand All @@ -16,10 +15,8 @@ namespace Funcky.Analyzers.OrNone;
/// The other null checks (<c>?.</c>, <c>is null</c>, …) have no single idiomatic shape, so they are reported without a fix.</summary>
[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferOrNoneCodeFix))]
public sealed class PreferOrNoneCodeFix : CodeFixProvider
public sealed class PreferOrNoneCodeFix : ExtensionMemberCodeFix
{
private const string ExtensionsNamespace = "Funcky.Extensions";

private const string GetOrElseMethodName = "GetOrElse";

private enum FallbackKind
Expand All @@ -30,34 +27,27 @@ private enum FallbackKind

public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(PreferOrNoneAnalyzer.DiagnosticId);

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
// The replacement is `xs.FirstOrNone().GetOrElse(fallback)`.
protected override string GetTitle(ExtensionMemberRewrite rewrite)
=> $"Replace with '{OrNoneMemberAccess(rewrite).Name.Identifier.Text}().{GetOrElseMethodName}(…)'";

protected override ExtensionMemberRewrite? TryCreateRewrite(SyntaxNode root, SemanticModel semanticModel, Diagnostic diagnostic, CancellationToken cancellationToken)
=> root.FindInvocationExpression(diagnostic.Location.SourceSpan) is { Expression: MemberAccessExpressionSyntax memberAccess } invocation
&& diagnostic.Properties.TryGetValue(PreferOrNoneAnalyzer.OrNoneMethodNameProperty, out var orNoneMethodName)
&& orNoneMethodName is not null
&& FindCoalesceExpression(invocation) is { } coalesce
&& KeepsTheResultType(semanticModel, invocation, coalesce, cancellationToken)
&& ClassifyFallback(semanticModel, Unparenthesized(coalesce.Right), cancellationToken) is { } fallbackKind
? new ExtensionMemberRewrite(coalesce, GetOrElseInvocation(coalesce, invocation, memberAccess, orNoneMethodName, fallbackKind))
: null;

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
if (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false) is not { } root
|| await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false) is not { } semanticModel)
{
return;
}
/// <summary>The replacement binds to <c>Option.GetOrElse</c>, a member of <c>Funcky.Monads</c>, when its <c>…OrNone</c> receiver binds,
/// which it does only with the <c>using</c> for <c>Funcky.Extensions</c> in place. Any bound method therefore shows the directive is there.</summary>
protected override Task<Document?> TryApplyAsync(Document document, SyntaxNode root, SemanticModel semanticModel, ImmutableArray<ExtensionMemberRewrite> rewrites, CancellationToken cancellationToken)
=> Task.FromResult<Document?>(FunckyExtensionsImport.ReplaceWithExtensionMemberCalls(document, root, semanticModel, rewrites, static symbol => symbol is IMethodSymbol));

foreach (var diagnostic in context.Diagnostics)
{
if (root.FindInvocationExpression(diagnostic.Location.SourceSpan) is { Expression: MemberAccessExpressionSyntax memberAccess } invocation
&& diagnostic.Properties.TryGetValue(PreferOrNoneAnalyzer.OrNoneMethodNameProperty, out var orNoneMethodName)
&& orNoneMethodName is not null
&& FindCoalesceExpression(invocation) is { } coalesce
&& KeepsTheResultType(semanticModel, invocation, coalesce, context.CancellationToken)
&& ClassifyFallback(semanticModel, Unparenthesized(coalesce.Right), context.CancellationToken) is { } fallbackKind)
{
context.RegisterCodeFix(
CodeAction.Create(
$"Replace with '{orNoneMethodName}().{GetOrElseMethodName}(…)'",
cancellationToken => ReplaceWithGetOrElseAsync(context.Document, new Rewrite(coalesce, invocation, memberAccess, orNoneMethodName, fallbackKind), cancellationToken),
nameof(PreferOrNoneCodeFix)),
diagnostic);
}
}
}
private static MemberAccessExpressionSyntax OrNoneMemberAccess(ExtensionMemberRewrite rewrite)
=> (MemberAccessExpressionSyntax)((InvocationExpressionSyntax)((MemberAccessExpressionSyntax)((InvocationExpressionSyntax)rewrite.Replacement).Expression).Expression).Expression;

private static BinaryExpressionSyntax? FindCoalesceExpression(InvocationExpressionSyntax invocation)
=> SkipParentheses(invocation) is { Parent: BinaryExpressionSyntax { RawKind: (int)SyntaxKind.CoalesceExpression } coalesce } left && coalesce.Left == left
Expand Down Expand Up @@ -102,22 +92,14 @@ private static IEnumerable<IOperation> DescendantsAndSelfOutsideFunctions(IOpera
? []
: [operation, .. operation.ChildOperations.SelectMany(DescendantsAndSelfOutsideFunctions)];

private static async Task<Document> ReplaceWithGetOrElseAsync(Document document, Rewrite rewrite, CancellationToken cancellationToken)
private static InvocationExpressionSyntax GetOrElseInvocation(BinaryExpressionSyntax coalesce, InvocationExpressionSyntax invocation, MemberAccessExpressionSyntax memberAccess, string orNoneMethodName, FallbackKind fallbackKind)
{
if (await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) is not { } root
|| await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false) is not { } semanticModel)
{
return document;
}
var orNoneInvocation = invocation.WithExpression(memberAccess.WithName(RenamedTo(memberAccess.Name, orNoneMethodName)));

var orNoneInvocation = rewrite.Invocation.WithExpression(rewrite.MemberAccess.WithName(RenamedTo(rewrite.MemberAccess.Name, rewrite.OrNoneMethodName)));
var getOrElse = InvocationExpression(
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, orNoneInvocation.WithoutTrailingTrivia(), IdentifierName(GetOrElseMethodName)),
ArgumentList(SingletonSeparatedList(Argument(Fallback(Unparenthesized(rewrite.Coalesce.Right), rewrite.FallbackKind)))))
.WithTriviaFrom(rewrite.Coalesce);

var newRoot = root.ReplaceNode(rewrite.Coalesce, getOrElse);
return document.WithSyntaxRoot(IsExtensionsNamespaceImported(semanticModel, rewrite.Invocation, orNoneInvocation) ? newRoot : UsingDirectives.AddUsing(newRoot, newRoot.FindNode(rewrite.Coalesce.Span), ExtensionsNamespace));
ArgumentList(SingletonSeparatedList(Argument(Fallback(Unparenthesized(coalesce.Right), fallbackKind)))))
.WithTriviaFrom(coalesce);
}

/// <summary>Only the identifier changes: explicit type arguments (<c>FirstOrDefault&lt;object&gt;()</c>) select the element type and stay.</summary>
Expand All @@ -128,9 +110,4 @@ private static ExpressionSyntax Fallback(ExpressionSyntax fallback, FallbackKind
=> fallbackKind == FallbackKind.Lazy
? ParenthesizedLambdaExpression(fallback.WithoutTrivia())
: fallback.WithoutTrivia();

private static bool IsExtensionsNamespaceImported(SemanticModel semanticModel, InvocationExpressionSyntax original, InvocationExpressionSyntax orNoneInvocation)
=> semanticModel.GetSpeculativeSymbolInfo(original.SpanStart, orNoneInvocation, SpeculativeBindingOption.BindAsExpression) is { Symbol: IMethodSymbol } or { CandidateSymbols.IsEmpty: false };

private sealed record Rewrite(BinaryExpressionSyntax Coalesce, InvocationExpressionSyntax Invocation, MemberAccessExpressionSyntax MemberAccess, string OrNoneMethodName, FallbackKind FallbackKind);
}
76 changes: 76 additions & 0 deletions Funcky.Analyzers/Funcky.Analyzers.Test/OrNone/PreferOrNoneTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,82 @@ public static class C
await VerifyCS.VerifyCodeFixAsync(inputCode + FunckyStubs, fixedCode + FunckyStubs);
}

[Fact]
public async Task ThreeFixesThatAllNeedTheUsingAreAppliedInOnePass()
{
const string inputCode = """
using System.Collections.Generic;
using System.Linq;

public static class C
{
public static string First(IEnumerable<string> values) => {|λ1012:values.FirstOrDefault()|} ?? "fallback";

public static string Last(IEnumerable<string> values) => {|λ1012:values.LastOrDefault()|} ?? "fallback";

public static string Single(IEnumerable<string> values) => {|λ1012:values.SingleOrDefault()|} ?? "fallback";

public static string ElementAt(IEnumerable<string> values) => {|λ1012:values.ElementAtOrDefault(2)|} ?? "fallback";
}
""";
const string fixedCode = """
using System.Collections.Generic;
using System.Linq;
using Funcky.Extensions;

public static class C
{
public static string First(IEnumerable<string> values) => values.FirstOrNone().GetOrElse("fallback");

public static string Last(IEnumerable<string> values) => values.LastOrNone().GetOrElse("fallback");

public static string Single(IEnumerable<string> values) => values.SingleOrNone().GetOrElse("fallback");

public static string ElementAt(IEnumerable<string> values) => values.ElementAtOrNone(2).GetOrElse("fallback");
}
""";
await VerifyCS.VerifyCodeFixAsync(inputCode + FunckyStubs, fixedCode + FunckyStubs);
}

[Fact]
public async Task ThreeFixesInANamespaceThatAllNeedTheUsingAreAppliedInOnePass()
{
const string inputCode = """
namespace Example
{
using System.Collections.Generic;
using System.Linq;

public static class C
{
public static string First(IEnumerable<string> values) => {|λ1012:values.FirstOrDefault()|} ?? "fallback";

public static string Last(IEnumerable<string> values) => {|λ1012:values.LastOrDefault()|} ?? "fallback";

public static string Single(IEnumerable<string> values) => {|λ1012:values.SingleOrDefault()|} ?? "fallback";
}
}
""";
const string fixedCode = """
namespace Example
{
using System.Collections.Generic;
using System.Linq;
using Funcky.Extensions;

public static class C
{
public static string First(IEnumerable<string> values) => values.FirstOrNone().GetOrElse("fallback");

public static string Last(IEnumerable<string> values) => values.LastOrNone().GetOrElse("fallback");

public static string Single(IEnumerable<string> values) => values.SingleOrNone().GetOrElse("fallback");
}
}
""";
await VerifyCS.VerifyCodeFixAsync(inputCode + FunckyStubs, fixedCode + FunckyStubs);
}

[Fact]
public async Task OtherNullChecksAreReportedWithoutFix()
{
Expand Down
9 changes: 5 additions & 4 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ Funcky adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* [λ1203](https://polyadic.github.io/funcky/analyzer-rules/λ1203.html) `FunctionalAssert.Some(…)` → `Assert.Some(…)`.
* Fix: λ1101 names the receiver as written in its message (`Assert.Some`); for a static extension member it used to
print the unnamed extension block (`.Some`).
* Fix: the code fixes that add `using Funcky.Extensions;` (λ1201, λ1202, λ1203 and the `Option.FromBoolean` fix) share a
Fix-All provider that rewrites every flagged call of a document in one pass and adds the directive once. The batch
fixer treated three or more identical directive insertions as colliding edits and dropped those fixes, call rewrite
included, so a second Fix-All pass was needed ([#1009](https://github.com/polyadic/funcky/issues/1009)).
* Fix: the code fixes that add `using Funcky.Extensions;` (λ1012, λ1201, λ1202, λ1203 and the `Option.FromBoolean` fix)
share a Fix-All provider that rewrites every flagged call of a document in one pass and adds the directive once. The
batch fixer treated three or more identical directive insertions as colliding edits and dropped those fixes, call
rewrite included, so a second Fix-All pass was needed ([#1009](https://github.com/polyadic/funcky/issues/1009),
[#1020](https://github.com/polyadic/funcky/issues/1020)).

## Funcky 3.7.0 | Funcky.Analyzers 2.0.0

Expand Down
Loading