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
1 change: 1 addition & 0 deletions Documentation/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
* [λ1015](./analyzer-rules/λ1015.md)
* [λ1016](./analyzer-rules/λ1016.md)
* [λ1017](./analyzer-rules/λ1017.md)
* [λ1018](./analyzer-rules/λ1018.md)
* [λ1101](./analyzer-rules/λ1101.md)

[Changelog](changelog.md)
1 change: 1 addition & 0 deletions Documentation/src/analyzer-rules/analyzer-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ contains a rule.
| [λ1015](./λ1015.md) | Info | Fallback is computed eagerly |
| [λ1016](./λ1016.md) | Warning | Pass the fallback value directly |
| [λ1017](./λ1017.md) | Warning | Prefer the named function over the lambda |
| [λ1018](./λ1018.md) | Warning | Prefer `Flatten` over `SelectMany` with the identity function |
| [λ1101](./λ1101.md) | Warning | Assert can be simplified |

## Removed rules
Expand Down
47 changes: 47 additions & 0 deletions Documentation/src/analyzer-rules/λ1018.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# λ1018: Prefer `Flatten` over `SelectMany` with the identity function
`SelectMany(x => x)` collapses one level of nesting. `Flatten()` does the same and says so by name.

## Cause
`SelectMany` is called with the identity function, `x => x` or `Identity` from `Funcky.Functional`, on a nested
`Option<Option<T>>`, `Result<Result<T>>`, `Either<L, Either<L, R>>` or `IEnumerable<IEnumerable<T>>`. Only the `SelectMany`
of the monads and of `System.Linq.Enumerable` counts: `ParallelEnumerable` and `Queryable` have their own, which `Flatten`
would turn into a plain enumerable.

The overload with a result selector, query syntax (`from x in xs from y in x select y`) and `SelectMany` inside an
expression tree are not reported.

## Reason for rule
A selector that returns its argument is noise: the reader has to look at the lambda to find out that it does nothing.
`Flatten` states the intent and needs no selector. λ1017 would otherwise turn the lambda into `SelectMany(Identity)`,
which is still a roundabout way of writing `Flatten()`.

## How to fix violations
Replace the `SelectMany` call with `Flatten()`. The code fix does this and adds the `using Funcky.Monads;` or
`using Funcky.Extensions;` when the extension method is not yet in scope. Explicit type arguments select the element type
and are kept: `SelectMany<IEnumerable<string>, object>(x => x)` becomes `Flatten<object>()`. The fix is only offered when
the rewritten call reaches Funcky's `Flatten`; a `Flatten` member of the receiver's own type would win over the extension
method, so there the call is reported without a fix.

## Examples

### Disallowed
```cs
static Option<int> Example(Option<Option<int>> option)
=> option.SelectMany(x => x);

static IEnumerable<int> Example(IEnumerable<IEnumerable<int>> values)
=> values.SelectMany(Identity);
```

### Allowed
```cs
static Option<int> Example(Option<Option<int>> option)
=> option.Flatten();

static IEnumerable<int> Example(IEnumerable<IEnumerable<int>> values)
=> values.Flatten();

// The selector does something.
static Option<int> Example(Option<Option<int>> option)
=> option.SelectMany(inner => inner.Select(x => x + 1));
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Collections.Immutable;
using System.Composition;
using Funcky.Analyzers.CodeAnalysisExtensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace Funcky.Analyzers.Flatten;

/// <summary>Replaces <c>SelectMany(x => x)</c> with <c>Flatten()</c>, adding the using for the extension's namespace when needed.
/// The fix is only offered when the rewritten call binds to Funcky's <c>Flatten</c>: a <c>Flatten</c> member of the receiver's own
/// type wins over any extension method and would silently redirect the call.</summary>
[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferFlattenCodeFix))]
public sealed class PreferFlattenCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(PreferFlattenAnalyzer.DiagnosticId);

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

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;
}

foreach (var diagnostic in context.Diagnostics)
{
if (root.FindInvocationExpression(diagnostic.Location.SourceSpan) is { Expression: MemberAccessExpressionSyntax memberAccess } invocation
&& diagnostic.Properties.TryGetValue(PreferFlattenAnalyzer.FlattenNamespaceProperty, out var flattenNamespace)
&& flattenNamespace is not null
&& await ReplaceWithFlattenAsync(context.Document, root, semanticModel, invocation, memberAccess, flattenNamespace, context.CancellationToken).ConfigureAwait(false) is { } fixedDocument)
{
context.RegisterCodeFix(
CodeAction.Create(
$"Replace with '{PreferFlattenAnalyzer.FlattenMethodName}()'",
_ => Task.FromResult(fixedDocument),
nameof(PreferFlattenCodeFix)),
diagnostic);
}
}
}

/// <summary>The call is tried as is and, when that does not reach Funcky's <c>Flatten</c>, once more with the using added. The
/// using alone does not guarantee the binding (an instance member named <c>Flatten</c> still wins), so the result is checked in the
/// rewritten document; without a successful binding there is no fix.</summary>
private static async Task<Document?> ReplaceWithFlattenAsync(Document document, SyntaxNode root, SemanticModel semanticModel, InvocationExpressionSyntax invocation, MemberAccessExpressionSyntax memberAccess, string flattenNamespace, CancellationToken cancellationToken)
{
var annotation = new SyntaxAnnotation();
var flatten = invocation
.WithExpression(memberAccess.WithName(FlattenName(memberAccess.Name)))
.WithArgumentList(ArgumentList().WithTriviaFrom(invocation.ArgumentList))
.WithAdditionalAnnotations(annotation);

if (IsFunckyFlatten(semanticModel.GetSpeculativeSymbolInfo(invocation.SpanStart, flatten, SpeculativeBindingOption.BindAsExpression).Symbol, flattenNamespace))
{
return document.WithSyntaxRoot(root.ReplaceNode(invocation, flatten));
}

var rootWithReplacement = root.ReplaceNode(invocation, flatten);
var fixedDocument = document.WithSyntaxRoot(UsingDirectives.AddUsing(rootWithReplacement, rootWithReplacement.GetAnnotatedNodes(annotation).Single(), flattenNamespace));

return await fixedDocument.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) is { } fixedRoot
&& await fixedDocument.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false) is { } fixedSemanticModel
&& IsFunckyFlatten(fixedSemanticModel.GetSymbolInfo(fixedRoot.GetAnnotatedNodes(annotation).Single(), cancellationToken).Symbol, flattenNamespace)
? fixedDocument
: null;
}

/// <summary>Explicit type arguments select the result's element type (<c>SelectMany&lt;IEnumerable&lt;string&gt;, object&gt;(x => x)</c>
/// yields <c>IEnumerable&lt;object&gt;</c>), so the last one is carried over: <c>Flatten&lt;object&gt;()</c>. Where <c>Flatten</c> has
/// another arity the call does not bind and there is no fix.</summary>
private static SimpleNameSyntax FlattenName(SimpleNameSyntax selectManyName)
=> selectManyName is GenericNameSyntax { TypeArgumentList.Arguments: [.., var resultType] }
? GenericName(Identifier(PreferFlattenAnalyzer.FlattenMethodName), TypeArgumentList(SingletonSeparatedList(resultType.WithoutTrivia()))).WithTriviaFrom(selectManyName)
: IdentifierName(PreferFlattenAnalyzer.FlattenMethodName).WithTriviaFrom(selectManyName);

private static bool IsFunckyFlatten(ISymbol? symbol, string flattenNamespace)
=> symbol is IMethodSymbol { Name: PreferFlattenAnalyzer.FlattenMethodName, ReducedFrom: not null, ContainingNamespace: var containingNamespace }
&& containingNamespace.ToDisplayString() == flattenNamespace;
}
Loading
Loading