Skip to content

Commit

Permalink
Merge pull request #657 from danigutsch/prefer-readonly-struct
Browse files Browse the repository at this point in the history
Prefer readonly struct analyzer and codefix
  • Loading branch information
SteveDunn committed Aug 19, 2024
2 parents 3b3c04f + 464f353 commit 986f494
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Composition;
using Vogen.Diagnostics;

namespace Vogen.Rules.MakeStructReadonlyFixers;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MakeStructReadonlyCodeFixProvider)), Shared]
public sealed class MakeStructReadonlyCodeFixProvider : CodeFixProvider
{
private const string _title = "Make struct readonly";

public override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(RuleIdentifiers.UseReadonlyStructInsteadOfStruct);

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

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return;
}

var diagnostic = context.Diagnostics[0];
var diagnosticSpan = diagnostic.Location.SourceSpan;

var typeDeclarationSyntax = root.FindToken(diagnosticSpan.Start)
.Parent?
.AncestorsAndSelf()
.OfType<TypeDeclarationSyntax>()
.FirstOrDefault(syntax => syntax is StructDeclarationSyntax or RecordDeclarationSyntax);

if (typeDeclarationSyntax is null)
{
return;
}

context.RegisterCodeFix(
CodeAction.Create(
_title,
c => MakeStructReadonlyAsync(context.Document, typeDeclarationSyntax, c),
_title),
diagnostic);
}

private static async Task<Document> MakeStructReadonlyAsync(Document document, TypeDeclarationSyntax typeDeclarationSyntax, CancellationToken cancellationToken)
{
var readonlyModifier = SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword);

var newModifiers = typeDeclarationSyntax.Modifiers;

// Ensure that the readonly keyword is inserted before the partial keyword
if (newModifiers.Any(SyntaxKind.PartialKeyword))
{
var partialIndex = newModifiers.IndexOf(SyntaxKind.PartialKeyword);
newModifiers = newModifiers.Insert(partialIndex, readonlyModifier);
}
else
{
newModifiers = newModifiers.Add(readonlyModifier);
}

var newStructDeclaration = typeDeclarationSyntax.WithModifiers(newModifiers);

var root = await document.GetSyntaxRootAsync(cancellationToken);
var newRoot = root!.ReplaceNode(typeDeclarationSyntax, newStructDeclaration);
return document.WithSyntaxRoot(newRoot);
}
}
1 change: 1 addition & 0 deletions src/Vogen/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
VOG033 | Usage | Info | UseReadonlyStructInsteadOfStructAnalyzer

1 change: 1 addition & 0 deletions src/Vogen/Diagnostics/RuleIdentifiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ public static class RuleIdentifiers
public const string EfCoreTargetMustExplicitlySpecifyItsPrimitive = "VOG030";
public const string EfCoreTargetMustBeAVo = "VOG031";
public const string DoNotThrowFromUserCode = "VOG032";
public const string UseReadonlyStructInsteadOfStruct = "VOG033";
}
79 changes: 79 additions & 0 deletions src/Vogen/Rules/PreferReadonlyStructAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Linq;
using Vogen.Diagnostics;

namespace Vogen.Rules;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class PreferReadonlyStructAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor _rule = new(
RuleIdentifiers.UseReadonlyStructInsteadOfStruct,
"Use readonly struct instead of struct",
"Type '{0}' should be a readonly struct",
RuleCategories.Usage,
DiagnosticSeverity.Info,
isEnabledByDefault: true,
description:
"The struct is not readonly. This can lead to invalid value objects in your domain. Use readonly struct instead.");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(_rule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.StructDeclaration, SyntaxKind.RecordStructDeclaration);
}

private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
if (context.Node is not TypeDeclarationSyntax typeDeclaration)
{
return;
}

var symbol = context.SemanticModel.GetDeclaredSymbol(typeDeclaration);
if (symbol is null)
{
return;
}

// readonly struct became available in C# 7.2
var languageVersion = GetLanguageVersion(context);
if (languageVersion < LanguageVersion.CSharp7_2)
{
return;
}

if (!VoFilter.IsTarget(symbol))
{
return;
}

if (symbol.IsReadOnly)
{
return;
}

ReportDiagnostic(context, symbol);
}

private static void ReportDiagnostic(SyntaxNodeAnalysisContext context, INamedTypeSymbol symbol)
{
var diagnostic = Diagnostic.Create(_rule, symbol.Locations[0], symbol.Name);
context.ReportDiagnostic(diagnostic);
}

private static LanguageVersion GetLanguageVersion(SyntaxNodeAnalysisContext context)
{
var compilation = context.SemanticModel.Compilation;
var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options;
return parseOptions.LanguageVersion;
}
}
116 changes: 116 additions & 0 deletions tests/AnalyzerTests/PreferReadonlyStructsAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Testing;
using System.Threading.Tasks;
using VerifyCS = AnalyzerTests.Verifiers.CSharpCodeFixVerifier<Vogen.Rules.PreferReadonlyStructAnalyzer, Vogen.Rules.MakeStructReadonlyFixers.MakeStructReadonlyCodeFixProvider>;

namespace AnalyzerTests;

public class PreferReadonlyStructsAnalyzerTests
{
[Theory]
[InlineData("class")]
[InlineData("record")]
[InlineData("record class")]
public async Task Does_not_trigger_if_not_struct(string type)
{
var source = $$"""
using Vogen;

namespace Whatever;

[ValueObject<int>]
public partial {{type}} CustomerId { }
""";

var test = new VerifyCS.Test
{
TestState =
{
Sources = { source }
},
CompilerDiagnostics = CompilerDiagnostics.Suggestions,
ReferenceAssemblies = References.Net80AndOurs.Value,
};

test.DisabledDiagnostics.Add("CS1591");

await test.RunAsync();
}

[Theory]
[InlineData("struct")]
[InlineData("record struct")]
public async Task Does_not_trigger_when_struct_is_readonly(string type)
{
var source = $$"""
using Vogen;

namespace Whatever;

[ValueObject<int>]
public readonly partial {{type}} CustomerId { }
""";

var test = new VerifyCS.Test
{
TestState =
{
Sources = { source }
},
CompilerDiagnostics = CompilerDiagnostics.Suggestions,
ReferenceAssemblies = References.Net80AndOurs.Value,
};

test.DisabledDiagnostics.Add("CS1591");

await test.RunAsync();
}

[Theory]
[InlineData("struct", "")]
[InlineData("record struct", "")]
[InlineData("struct", "<int>")]
[InlineData("record struct", "<int>")]
[InlineData("struct", "<string>")]
[InlineData("record struct", "<string>")]
public async Task Triggers_when_struct_is_not_partial(string modifier, string genericType)
{
var source = $$"""
using Vogen;
namespace Whatever;

[ValueObject{{genericType}}]
public partial {{modifier}} {|#0:DocumentId|} { }
""";

var fixedCode = $$"""
using Vogen;
namespace Whatever;

[ValueObject{{genericType}}]
public readonly partial {{modifier}} {|#0:DocumentId|} { }
""";

var expectedDiagnostic = VerifyCS
.Diagnostic("VOG033")
.WithSeverity(DiagnosticSeverity.Info)
.WithLocation(0)
.WithArguments("DocumentId");

var test = new VerifyCS.Test
{
TestState =
{
Sources = { source }
},
CompilerDiagnostics = CompilerDiagnostics.Suggestions,
ReferenceAssemblies = References.Net80AndOurs.Value,
ExpectedDiagnostics = { expectedDiagnostic },
FixedCode = fixedCode
};

test.DisabledDiagnostics.Add("CS1591");

await test.RunAsync();
}
}

0 comments on commit 986f494

Please sign in to comment.