Skip to content

Commit 7ee1068

Browse files
authored
Publish three new analyzers (#108)
Adds FL0024, FL0025, and FL0026.
1 parent 3b9cf9a commit 7ee1068

12 files changed

Lines changed: 1121 additions & 2 deletions

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Repository Guidance
2+
3+
* When adding a new analyzer, bump the minor version in `Directory.Build.props` and reset the patch version to zero, for example `1.6.2` to `1.7.0`.
4+
* Add the release note for a new analyzer under the matching minor-version heading.

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project>
22

33
<PropertyGroup>
4-
<VersionPrefix>1.6.2</VersionPrefix>
4+
<VersionPrefix>1.7.0</VersionPrefix>
55
<NoWarn>$(NoWarn);1591;1998;NU5105;CA1014;CA1508;CA1859;NU1507</NoWarn>
66
<GitHubOrganization>Faithlife</GitHubOrganization>
77
<RepositoryName>FaithlifeAnalyzers</RepositoryName>

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dotnet_diagnostic.FL0009.severity = none
2626
## Analyzers
2727

2828
| ID | Description |
29-
|---|---|
29+
| --- | --- |
3030
| [FL0001](docs/FL0001.md) | `AsyncWorkItem.Current` must only be used in methods that return `IEnumerable<AsyncAction>` |
3131
| [FL0002](docs/FL0002.md) | Optional `StringComparison` arguments must always be specified |
3232
| [FL0003](docs/FL0003.md) | `UntilCanceled()` may only be used in methods that return `IEnumerable<AsyncAction>` |
@@ -50,6 +50,8 @@ dotnet_diagnostic.FL0009.severity = none
5050
| [FL0021](docs/FL0021.md) | Use null propagation |
5151
| [FL0022](docs/FL0022.md) | Use AsyncMethodContext.WorkState |
5252
| [FL0023](docs/FL0023.md) | Replace obsolete Logos.Common.Logging.Extensions extension methods |
53+
| [FL0024](docs/FL0024.md) | Lambda operators should end the previous line |
54+
| [FL0025](docs/FL0025.md) | Private fields should be defined last |
5355
| [FL0026](docs/FL0026.md) | Use `InvariantConvert` |
5456

5557
## How to Help

ReleaseNotes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Release Notes
22

3+
## 1.7.0
4+
5+
* Add [FL0024](https://github.com/Faithlife/FaithlifeAnalyzers/blob/master/docs/FL0024.md): Lambda operators should end the previous line.
6+
* Add [FL0025](https://github.com/Faithlife/FaithlifeAnalyzers/blob/master/docs/FL0025.md): Private fields should be defined last.
7+
* Add [FL0026](https://github.com/Faithlife/FaithlifeAnalyzers/blob/master/docs/FL0026.md): Use `InvariantConvert`.
8+
39
## 1.6.2
410

511
* Move analyzer documentation from wiki to repository content.

docs/FL0024.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# FL0024: Lambda operators should end the previous line
2+
3+
Lambda expressions, expression-bodied members, and switch expression arms should keep the `=>` operator at the end of the previous line instead of placing it at the beginning of the next line.
4+
5+
```csharp
6+
// discouraged
7+
public string FormatCustomerDisplayName(CustomerAccountRecord customerAccount)
8+
=> customerAccount.DisplayName.Trim();
9+
10+
public IReadOnlyList<string> SelectOrderNumbersForSummary(OrderSummaryMode summaryMode)
11+
{
12+
return summaryMode switch
13+
{
14+
OrderSummaryMode.PendingFulfillment
15+
=> customerOrders
16+
.Where(customerOrderSummaryItem
17+
=> customerOrderSummaryItem.IsPaid && customerOrderSummaryItem.NeedsFulfillment)
18+
.Select(customerOrderSummaryItem => customerOrderSummaryItem.OrderNumber)
19+
.ToList(),
20+
_ => [],
21+
};
22+
}
23+
```
24+
25+
```csharp
26+
// preferred
27+
public string FormatCustomerDisplayName(CustomerAccountRecord customerAccount) =>
28+
customerAccount.DisplayName.Trim();
29+
30+
public IReadOnlyList<string> SelectOrderNumbersForSummary(OrderSummaryMode summaryMode)
31+
{
32+
return summaryMode switch
33+
{
34+
OrderSummaryMode.PendingFulfillment =>
35+
customerOrders
36+
.Where(customerOrderSummaryItem =>
37+
customerOrderSummaryItem.IsPaid && customerOrderSummaryItem.NeedsFulfillment)
38+
.Select(customerOrderSummaryItem => customerOrderSummaryItem.OrderNumber)
39+
.ToList(),
40+
_ => [],
41+
};
42+
}
43+
```
44+
45+
This keeps the lambda operator attached to the preceding lambda signature, member declaration, or switch pattern, and follows the Faithlife line-break style for operators.

docs/FL0025.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# FL0025: Private fields should be defined last
2+
3+
## Summary
4+
5+
Detects types whose first members are private fields.
6+
7+
## Example
8+
9+
### Before
10+
11+
```csharp
12+
public sealed class Example
13+
{
14+
private readonly int _value;
15+
16+
public int Value => _value;
17+
}
18+
```
19+
20+
### After
21+
22+
```csharp
23+
public sealed class Example
24+
{
25+
public int Value => _value;
26+
27+
private readonly int _value;
28+
}
29+
```
30+
31+
## Rationale
32+
33+
Keeping private fields at the end of a type makes the public API and higher-level members easier to find first.
34+
35+
## Severity
36+
37+
**Warning** - Encourages a consistent member ordering convention.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
using Microsoft.CodeAnalysis.Text;
7+
8+
namespace Faithlife.Analyzers;
9+
10+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
11+
public sealed class LambdaOperatorAnalyzer : DiagnosticAnalyzer
12+
{
13+
public override void Initialize(AnalysisContext context)
14+
{
15+
context.EnableConcurrentExecution();
16+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
17+
18+
context.RegisterSyntaxNodeAction(AnalyzeSyntax,
19+
SyntaxKind.ArrowExpressionClause,
20+
SyntaxKind.SimpleLambdaExpression,
21+
SyntaxKind.ParenthesizedLambdaExpression,
22+
SyntaxKind.SwitchExpressionArm);
23+
}
24+
25+
public const string DiagnosticId = "FL0024";
26+
27+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [s_rule];
28+
29+
private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
30+
{
31+
var lambdaOperatorToken = GetLambdaOperatorToken(context.Node);
32+
if (!lambdaOperatorToken.IsKind(SyntaxKind.EqualsGreaterThanToken))
33+
return;
34+
35+
var previousToken = lambdaOperatorToken.GetPreviousToken();
36+
if (previousToken.IsKind(SyntaxKind.None))
37+
return;
38+
39+
var sourceText = lambdaOperatorToken.SyntaxTree.GetText(context.CancellationToken);
40+
if (!IsFirstNonWhitespaceOnLine(sourceText, lambdaOperatorToken) ||
41+
!ContainsOnlyWhitespace(sourceText, TextSpan.FromBounds(previousToken.Span.End, lambdaOperatorToken.SpanStart)))
42+
{
43+
return;
44+
}
45+
46+
context.ReportDiagnostic(Diagnostic.Create(s_rule, lambdaOperatorToken.GetLocation()));
47+
}
48+
49+
private static SyntaxToken GetLambdaOperatorToken(SyntaxNode node) =>
50+
node switch
51+
{
52+
ArrowExpressionClauseSyntax arrowExpressionClause => arrowExpressionClause.ArrowToken,
53+
ParenthesizedLambdaExpressionSyntax parenthesizedLambdaExpression => parenthesizedLambdaExpression.ArrowToken,
54+
SimpleLambdaExpressionSyntax simpleLambdaExpression => simpleLambdaExpression.ArrowToken,
55+
SwitchExpressionArmSyntax switchExpressionArm => switchExpressionArm.EqualsGreaterThanToken,
56+
_ => default,
57+
};
58+
59+
private static bool IsFirstNonWhitespaceOnLine(SourceText sourceText, SyntaxToken token)
60+
{
61+
var line = sourceText.Lines.GetLineFromPosition(token.SpanStart);
62+
return ContainsOnlyWhitespace(sourceText, TextSpan.FromBounds(line.Start, token.SpanStart));
63+
}
64+
65+
private static bool ContainsOnlyWhitespace(SourceText sourceText, TextSpan span)
66+
{
67+
for (int index = span.Start; index < span.End; index++)
68+
{
69+
if (!char.IsWhiteSpace(sourceText[index]))
70+
return false;
71+
}
72+
73+
return true;
74+
}
75+
76+
private static readonly DiagnosticDescriptor s_rule = new(
77+
id: DiagnosticId,
78+
title: "Lambda operator should end the previous line",
79+
messageFormat: "Move => to the end of the previous line",
80+
category: "Style",
81+
defaultSeverity: DiagnosticSeverity.Warning,
82+
isEnabledByDefault: true,
83+
helpLinkUri: $"https://github.com/Faithlife/FaithlifeAnalyzers/blob/-/docs/{DiagnosticId}.md");
84+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CodeActions;
5+
using Microsoft.CodeAnalysis.CodeFixes;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
using Microsoft.CodeAnalysis.Text;
8+
9+
namespace Faithlife.Analyzers;
10+
11+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LambdaOperatorCodeFixProvider)), Shared]
12+
public sealed class LambdaOperatorCodeFixProvider : CodeFixProvider
13+
{
14+
public override ImmutableArray<string> FixableDiagnosticIds => [LambdaOperatorAnalyzer.DiagnosticId];
15+
16+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
17+
18+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
19+
{
20+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
21+
if (root is null)
22+
return;
23+
24+
var diagnostic = context.Diagnostics.First();
25+
var lambdaOperatorToken = root.FindToken(diagnostic.Location.SourceSpan.Start);
26+
if (!lambdaOperatorToken.IsKind(SyntaxKind.EqualsGreaterThanToken))
27+
return;
28+
29+
var previousToken = lambdaOperatorToken.GetPreviousToken();
30+
if (previousToken.IsKind(SyntaxKind.None))
31+
return;
32+
33+
context.RegisterCodeFix(
34+
CodeAction.Create(
35+
title: "Move => to the previous line",
36+
createChangedDocument: token => MoveLambdaOperatorToPreviousLineAsync(context.Document, previousToken, lambdaOperatorToken, token),
37+
"move-lambda-operator"),
38+
diagnostic);
39+
}
40+
41+
private static async Task<Document> MoveLambdaOperatorToPreviousLineAsync(Document document, SyntaxToken previousToken, SyntaxToken lambdaOperatorToken, CancellationToken cancellationToken)
42+
{
43+
var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
44+
var nextToken = lambdaOperatorToken.GetNextToken();
45+
var whitespaceBeforeLambdaOperator = sourceText.ToString(TextSpan.FromBounds(previousToken.Span.End, lambdaOperatorToken.SpanStart));
46+
var textAfterLambdaOperator = sourceText.ToString(TextSpan.FromBounds(lambdaOperatorToken.Span.End, nextToken.SpanStart));
47+
var changedText = ContainsOnlyWhitespace(textAfterLambdaOperator) ?
48+
sourceText.WithChanges(
49+
new TextChange(TextSpan.FromBounds(previousToken.Span.End, lambdaOperatorToken.SpanStart), " "),
50+
new TextChange(TextSpan.FromBounds(lambdaOperatorToken.Span.End, nextToken.SpanStart), whitespaceBeforeLambdaOperator)) :
51+
sourceText.WithChanges(new TextChange(TextSpan.FromBounds(previousToken.Span.End, lambdaOperatorToken.SpanStart), " "));
52+
return document.WithText(changedText);
53+
}
54+
55+
private static bool ContainsOnlyWhitespace(string text) => text.All(char.IsWhiteSpace);
56+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Faithlife.Analyzers;
8+
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public sealed class PrivateFieldsLastAnalyzer : DiagnosticAnalyzer
11+
{
12+
public const string DiagnosticId = "FL0025";
13+
14+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [s_rule];
15+
16+
public override void Initialize(AnalysisContext context)
17+
{
18+
context.EnableConcurrentExecution();
19+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
20+
21+
context.RegisterSyntaxNodeAction(AnalyzeType, SyntaxKind.ClassDeclaration, SyntaxKind.StructDeclaration, SyntaxKind.RecordDeclaration, SyntaxKind.RecordStructDeclaration);
22+
}
23+
24+
private static void AnalyzeType(SyntaxNodeAnalysisContext context)
25+
{
26+
var typeDeclaration = (TypeDeclarationSyntax) context.Node;
27+
if (typeDeclaration.Members.Count < 2)
28+
return;
29+
30+
var leadingPrivateFieldCount = GetLeadingPrivateFieldCount(typeDeclaration.Members, member => IsPrivateField(context.SemanticModel, member, context.CancellationToken));
31+
if (leadingPrivateFieldCount is 0 || leadingPrivateFieldCount == typeDeclaration.Members.Count)
32+
return;
33+
34+
context.ReportDiagnostic(Diagnostic.Create(s_rule, typeDeclaration.Members[0].GetLocation()));
35+
}
36+
37+
internal static int GetLeadingPrivateFieldCount(SyntaxList<MemberDeclarationSyntax> members, Func<MemberDeclarationSyntax, bool> isPrivateField)
38+
{
39+
var privateFieldCount = 0;
40+
while (privateFieldCount < members.Count && isPrivateField(members[privateFieldCount]))
41+
privateFieldCount++;
42+
43+
return privateFieldCount;
44+
}
45+
46+
internal static bool IsPrivateField(SemanticModel semanticModel, MemberDeclarationSyntax member, CancellationToken cancellationToken)
47+
{
48+
if (member is not FieldDeclarationSyntax { Declaration.Variables.Count: > 0 } field)
49+
return false;
50+
51+
return semanticModel.GetDeclaredSymbol(field.Declaration.Variables[0], cancellationToken) is IFieldSymbol
52+
{
53+
DeclaredAccessibility: Accessibility.Private,
54+
};
55+
}
56+
57+
private static readonly DiagnosticDescriptor s_rule = new(
58+
id: DiagnosticId,
59+
title: "Private fields should be defined last",
60+
messageFormat: "Move private fields to the end of the type",
61+
category: "Usage",
62+
defaultSeverity: DiagnosticSeverity.Warning,
63+
isEnabledByDefault: true,
64+
helpLinkUri: $"https://github.com/Faithlife/FaithlifeAnalyzers/blob/-/docs/{DiagnosticId}.md");
65+
}

0 commit comments

Comments
 (0)