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
6 changes: 6 additions & 0 deletions .github/workflows/build-and-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ jobs:
- name: Build
run: dotnet build Nota.CodeAnalysis.sln --configuration Release

# Unit tests for this repository's own analysers (NOTA0003): what they report, and that the
# code fix moves operators without changing anything but whitespace. The scripts below prove
# the rule reaches a consumer; these prove it is right when it gets there.
- name: Test the analysers
run: dotnet test Nota.CodeAnalysis.sln --configuration Release --no-build

- name: Verify the rules report
run: ./Nota.CodeAnalysis.Verification/verify.sh

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Code fixes for Nota.CodeAnalysis.Analysers. A separate assembly because a fix needs
Microsoft.CodeAnalysis.Workspaces, which the command-line compiler does not carry; an analyser
assembly that references it can fail to load there (RS1038). Both go into the package's
analyzers/dotnet/cs: the compiler only instantiates analysers, so it never loads this one, while
Rider, Visual Studio and dotnet format load both.
-->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Nota.CodeAnalysis.Analysers\Nota.CodeAnalysis.Analysers.csproj" />
</ItemGroup>

</Project>
67 changes: 67 additions & 0 deletions Nota.CodeAnalysis.Analysers.CodeFixes/OperatorPlacementCodeFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Text;

namespace Nota.CodeAnalysis.Analysers.CodeFixes;

/// <summary>
/// Fixes NOTA0003 by moving the operator to the end of the line before it.
/// </summary>
[ExportCodeFixProvider(Microsoft.CodeAnalysis.LanguageNames.CSharp, Name = nameof(OperatorPlacementCodeFix))]
[Shared]
public sealed class OperatorPlacementCodeFix : CodeFixProvider
{
private const string Title = "Move the operator to the end of the line before";

/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(OperatorPlacementAnalyser.DiagnosticId);

/// <inheritdoc/>
public override FixAllProvider GetFixAllProvider()
{
return FixAllProvider.Create(FixAllAsync);
}

/// <inheritdoc/>
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
foreach (Diagnostic diagnostic in context.Diagnostics)
{
context.RegisterCodeFix(
CodeAction.Create(Title,
cancellationToken => FixAsync(context.Document, [diagnostic], cancellationToken),
nameof(OperatorPlacementCodeFix)),
diagnostic);
}

return Task.CompletedTask;
}

private static async Task<Document?> FixAllAsync(FixAllContext context, Document document, ImmutableArray<Diagnostic> diagnostics)
{
return await FixAsync(document, diagnostics, context.CancellationToken).ConfigureAwait(false);
}

private static async Task<Document> FixAsync(Document document, IEnumerable<Diagnostic> diagnostics, CancellationToken cancellationToken)
{
SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
SourceText text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);

if (root is null)
{
return document;
}

HashSet<int> positions = [.. diagnostics.Select(d => d.Location.SourceSpan.Start)];

return document.WithText(OperatorPlacement.Apply(root, text, positions));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.6.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Nota.CodeAnalysis.Analysers\Nota.CodeAnalysis.Analysers.csproj" />
<ProjectReference Include="..\Nota.CodeAnalysis.Analysers.CodeFixes\Nota.CodeAnalysis.Analysers.CodeFixes.csproj" />
</ItemGroup>

</Project>
128 changes: 128 additions & 0 deletions Nota.CodeAnalysis.Analysers.Test/OperatorPlacementAnalyserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;

using AwesomeAssertions;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Nota.CodeAnalysis.Analysers.Test;

public class OperatorPlacementAnalyserTests
{
/// <summary>
/// Runs NOTA0003 over <paramref name="body"/>, placed inside a method, and returns the operators it
/// reported.
/// </summary>
internal static async Task<IReadOnlyList<string>> ReportedOperatorsAsync(string body)
{
IReadOnlyList<Diagnostic> diagnostics = await DiagnosticsAsync(Wrap(body));

return diagnostics.Select(d => d.Location.SourceTree!.GetText().ToString(d.Location.SourceSpan)).ToList();
}

internal static async Task<IReadOnlyList<Diagnostic>> DiagnosticsAsync(string source)
{
SyntaxTree tree = CSharpSyntaxTree.ParseText(source, path: "Test.cs");
CSharpCompilation compilation = CSharpCompilation.Create("Test", [tree]);
ImmutableArray<Diagnostic> diagnostics = await compilation.WithAnalyzers([new OperatorPlacementAnalyser()])
.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken);

return diagnostics.Where(d => d.Id == OperatorPlacementAnalyser.DiagnosticId).ToList();
}

internal static string Wrap(string body)
{
return "class C\n{\n object M(bool a, bool b, int x, int y, string? s, object o)\n {\n" + body + "\n }\n}\n";
}

[Theory]
[InlineData("return a\n && b;", "&&")]
[InlineData("return a\n || b;", "||")]
[InlineData("return x\n + y;", "+")]
[InlineData("return x\n - y;", "-")]
[InlineData("return x\n * y;", "*")]
[InlineData("return x\n | y;", "|")]
[InlineData("return x\n != y;", "!=")]
[InlineData("return x\n < y;", "<")]
[InlineData("return s\n ?? \"\";", "??")]
[InlineData("return o\n as string;", "as")]
[InlineData("return o\n is int;", "is")]
[InlineData("return o\n is int i;", "is")]
[InlineData("return o is 1\n or 2;", "or")]
[InlineData("return o is not null\n and not 2;", "and")]
public async Task ReportsAnOperatorThatLeadsItsLine(string body, string op)
{
IReadOnlyList<string> reported = await ReportedOperatorsAsync(body);

reported.Should().Equal(op);
}

[Fact]
public async Task ReportsBothHalvesOfAConditional()
{
IReadOnlyList<string> reported = await ReportedOperatorsAsync("return a\n ? x\n : y;");

reported.Should().Equal("?", ":");
}

[Fact]
public async Task ReportsEachOperatorOfAChain()
{
IReadOnlyList<string> reported = await ReportedOperatorsAsync("return a\n && b\n && a\n || b;");

reported.Should().HaveCount(3);
}

[Theory]
[InlineData("return a &&\n b;")]
[InlineData("return a ?\n x :\n y;")]
[InlineData("return o is 1 or\n 2;")]
[InlineData("return a && b;")]
[InlineData("return a ? x : y;")]
[InlineData("return a\n .ToString();")]
[InlineData("System.Func<int> f = ()\n => 1;\nreturn f;")]
[InlineData("return @\"x\n && y\";")]
[InlineData("// a\n// && b\nreturn a;")]
public async Task LeavesEverythingElse(string body)
{
IReadOnlyList<string> reported = await ReportedOperatorsAsync(body);

reported.Should().BeEmpty();
}

/// <summary>SA1128 wants a constructor initializer on its own line, and this must not argue.</summary>
[Fact]
public async Task LeavesAConstructorInitializerAndABaseList()
{
const string source = "class B\n{\n public B(int x)\n {\n }\n}\n\nclass D\n : B\n{\n public D()\n : base(1)\n {\n }\n}\n";

IReadOnlyList<Diagnostic> diagnostics = await DiagnosticsAsync(source);

diagnostics.Should().BeEmpty();
}

[Fact]
public async Task LeavesGeneratedCode()
{
const string source = "// <auto-generated />\nclass C\n{\n bool M(bool a, bool b)\n {\n return a\n && b;\n }\n}\n";

IReadOnlyList<Diagnostic> diagnostics = await DiagnosticsAsync(source);

diagnostics.Should().BeEmpty();
}

[Fact]
public async Task ReportsAtTheOperator()
{
IReadOnlyList<Diagnostic> diagnostics = await DiagnosticsAsync(Wrap("return a\n && b;"));

Diagnostic diagnostic = diagnostics.Should().ContainSingle().Subject;
diagnostic.Location.GetLineSpan().StartLinePosition.Should().Be(new Microsoft.CodeAnalysis.Text.LinePosition(5, 4));
diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture).Should().StartWith("'&&' leads its line");
diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning);
}
}
Loading
Loading