Skip to content

Commit

Permalink
Refactory ctor and implicit conversions as template-based
Browse files Browse the repository at this point in the history
This simplifies mantainability down the road.

This also allowed conditional generation of the primary constructor if users already provide one with the required Value parameter. A pair of codefixes are provided to either remove a non-compliant ctor entirely or renaming the parameter to `Value` as required.
  • Loading branch information
kzu committed Nov 28, 2024
1 parent c247fa0 commit b4dbdc0
Show file tree
Hide file tree
Showing 14 changed files with 274 additions and 55 deletions.
10 changes: 10 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ be offered to correct it.
The relevant constructor and `Value` property will be generated for you, as well as
as a few other common interfaces, such as `IComparable<T>`, `IParsable<TSelf>`, etc.

If you want to customize the primary constructor (i.e. to add custom attributes),
you can provide it yourself too:

```csharp
public readonly partial record struct ProductId(int Value) : IStructId<int>;
```

It must contain a single parameter named `Value` (and codefixes will offer to rename or
remove it if you don't need it anymore).

### EF Core

If you are using EF Core, the package will automatically generate the necessary value converters,
Expand Down
57 changes: 10 additions & 47 deletions src/StructId.Analyzer/ConstructorGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,54 +1,17 @@
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace StructId;

[Generator(LanguageNames.CSharp)]
public class ConstructorGenerator : IIncrementalGenerator
public class ConstructorGenerator() : TemplateGenerator(
"System.Object",
ThisAssembly.Resources.Templates.Constructor.Text,
ThisAssembly.Resources.Templates.ConstructorT.Text,
ReferenceCheck.TypeExists)
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var ids = context.CompilationProvider
.SelectMany((x, _) => x.Assembly.GetAllTypes().OfType<INamedTypeSymbol>())
.Where(t => t.IsStructId())
.Where(t => t.IsPartial());

context.RegisterSourceOutput(ids, GenerateCode);
}

void GenerateCode(SourceProductionContext context, INamedTypeSymbol symbol)
{
var ns = symbol.ContainingNamespace.Equals(symbol.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default)
? null
: symbol.ContainingNamespace.ToDisplayString();

// Generic IStructId<T> -> T, otherwise string
var type = symbol.AllInterfaces.First(x => x.Name == "IStructId").TypeArguments.Select(x => x.GetTypeName(ns)).FirstOrDefault() ?? "string";

var kind = symbol.IsRecord && symbol.IsValueType ?
"record struct" :
symbol.IsRecord ?
"record" :
"class";

var output = new StringBuilder();

output.AppendLine("// <auto-generated/>");
if (ns != null)
output.AppendLine($"namespace {ns};");

output.AppendLine(
$$"""

[System.CodeDom.Compiler.GeneratedCode("StructId", "{{ThisAssembly.Info.InformationalVersion}}")]
partial {{kind}} {{symbol.Name}}({{type}} Value)
{
public static implicit operator {{type}}({{symbol.Name}} id) => id.Value;
public static explicit operator {{symbol.Name}}({{type}} value) => new(value);
}
""");

context.AddSource($"{symbol.ToFileName()}.cs", output.ToString());
}
}
protected override IncrementalValuesProvider<TemplateArgs> OnInitialize(IncrementalGeneratorInitializationContext context, IncrementalValuesProvider<TemplateArgs> source)
=> base.OnInitialize(context, source.Where(x
=> x.StructId.DeclaringSyntaxReferences.Select(r => r.GetSyntax()).OfType<TypeDeclarationSyntax>().All(s => s.ParameterList == null)));
}
10 changes: 10 additions & 0 deletions src/StructId.Analyzer/ConversionGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.CodeAnalysis;

namespace StructId;

[Generator(LanguageNames.CSharp)]
public class ConversionGenerator() : TemplateGenerator(
"System.Object",
ThisAssembly.Resources.Templates.Conversion.Text,
ThisAssembly.Resources.Templates.ConversionT.Text,
ReferenceCheck.TypeExists);
14 changes: 10 additions & 4 deletions src/StructId.Analyzer/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ namespace StructId;

public static class Diagnostics
{
/// <summary>
/// SID001: StructId must be a partial readonly record struct.
/// </summary>
public static DiagnosticDescriptor MustBeRecordStruct { get; } = new(
"SID001",
"Struct ids must be partial readonly record structs",
Expand All @@ -15,4 +12,13 @@ public static class Diagnostics
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID001.md");
}

public static DiagnosticDescriptor MustHaveValueConstructor { get; } = new(
"SID002",
"Struct id custom constructor must provide a single Value parameter",
"Custom constructor for '{0}' must have a Value parameter",
"Build",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID002.md");
}
25 changes: 23 additions & 2 deletions src/StructId.Analyzer/RecordAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
Expand All @@ -12,12 +14,15 @@ namespace StructId;
public class RecordAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(MustBeRecordStruct);
=> ImmutableArray.Create(MustBeRecordStruct, MustHaveValueConstructor);

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

if (!Debugger.IsAttached)
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.StructDeclaration);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.RecordDeclaration);
Expand Down Expand Up @@ -53,5 +58,21 @@ static void Analyze(SyntaxNodeAnalysisContext context)
else
context.ReportDiagnostic(Diagnostic.Create(MustBeRecordStruct, typeDeclaration.Identifier.GetLocation(), symbol.Name));
}

if (typeDeclaration.ParameterList is null)
return;

// If there are parameters, it must be only one, be named Value and be either
// type string (if implementing IStructId) or the TId (if implementing IStructId<TId>)
if (typeDeclaration.ParameterList.Parameters.Count != 1)
{
context.ReportDiagnostic(Diagnostic.Create(MustHaveValueConstructor, typeDeclaration.ParameterList.GetLocation(), symbol.Name));
return;
}

var parameter = typeDeclaration.ParameterList.Parameters[0];
if (parameter.Identifier.Text != "Value")
context.ReportDiagnostic(Diagnostic.Create(MustHaveValueConstructor, parameter.Identifier.GetLocation(), symbol.Name));

}
}
48 changes: 48 additions & 0 deletions src/StructId.CodeFix/RemoveCtorCodeFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace StructId;

[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public class RemoveCtorCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(Diagnostics.MustHaveValueConstructor.Id);

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 == null)
return;

var declaration = root.FindNode(context.Span).FirstAncestorOrSelf<TypeDeclarationSyntax>();
if (declaration == null)
return;

if (declaration.ParameterList?.Parameters.Count == 1)
context.RegisterCodeFix(
new RemoveAction(context.Document, root, declaration),
context.Diagnostics);
}

public class RemoveAction(Document document, SyntaxNode root, TypeDeclarationSyntax declaration) : CodeAction
{
public override string Title => "Remove primary constructor to generate it automatically";
public override string EquivalenceKey => Title;

protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
{
return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(declaration,
declaration.WithParameterList(null))));
}
}
}
47 changes: 47 additions & 0 deletions src/StructId.CodeFix/RenameCtorCodeFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace StructId;

[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public class RenameCtorCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(Diagnostics.MustHaveValueConstructor.Id);

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 == null)
return;

var parameter = root.FindNode(context.Span).FirstAncestorOrSelf<ParameterSyntax>();
if (parameter == null)
return;

context.RegisterCodeFix(
new RenameAction(context.Document, root, parameter),
context.Diagnostics);
}

public class RenameAction(Document document, SyntaxNode root, ParameterSyntax parameter) : CodeAction
{
public override string Title => "Rename to 'Value' as required for struct ids";
public override string EquivalenceKey => Title;

protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
{
return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(parameter,
parameter.WithIdentifier(Identifier("Value")))));
}
}
}
20 changes: 20 additions & 0 deletions src/StructId.Tests/RecordAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,24 @@ public class UserId : {|#0:IStructId<int>|};

await test.RunAsync();
}

[Fact]
public async Task ReadonlyRecordStructWithNonValueConstructor()
{
var test = new Test
{
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
TestCode =
"""
using StructId;

public readonly partial record struct UserId(int {|#0:value|}) : IStructId<int>;
""",
}.WithAnalyzerStructId();

test.ExpectedDiagnostics.Add(Verifier.Diagnostic(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(3, 59));

await test.RunAsync();
}
}
74 changes: 74 additions & 0 deletions src/StructId.Tests/RecordCtorCodeFixTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<StructId.RecordAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

namespace StructId;

public class RecordCtorCodeFixTests
{
[Fact]
public async Task RenameValue()
{
var test = new CSharpCodeFixTest<RecordAnalyzer, RenameCtorCodeFix, DefaultVerifier>
{
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
TestCode =
"""
using StructId;

public readonly partial record struct UserId(int {|#0:foo|}) : {|#1:IStructId<int>|};
""",
FixedCode =
"""
using StructId;

public readonly partial record struct UserId(int Value) : IStructId<int>;
""",
}.WithCodeFixStructId();

test.ExpectedDiagnostics.Add(new DiagnosticResult(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(1));

// Don't propagate the expected diagnostics to the fixed code, it will have none of them
test.FixedState.InheritanceMode = StateInheritanceMode.Explicit;

await test.RunAsync();
}

[Fact]
public async Task RemoveCtor()
{
var test = new CSharpCodeFixTest<RecordAnalyzer, RemoveCtorCodeFix, DefaultVerifier>
{
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
TestCode =
"""
using StructId;

public readonly partial record struct UserId(int {|#0:foo|}) : {|#1:IStructId<int>|};
""",
FixedCode =
"""
using StructId;

public readonly partial record struct UserId: {|#0:IStructId<int>|};
""",
}.WithCodeFixStructId();

test.ExpectedDiagnostics.Add(new DiagnosticResult(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(1));

// Don't propagate the expected diagnostics to the fixed code, it will have none of them
test.FixedState.InheritanceMode = StateInheritanceMode.Explicit;
test.FixedState.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(0));

await test.RunAsync();
}

}
3 changes: 3 additions & 0 deletions src/StructId/Templates/Constructor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// <auto-generated />

readonly partial record struct Self(string Value);
3 changes: 3 additions & 0 deletions src/StructId/Templates/ConstructorT.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// <auto-generated />

readonly partial record struct TSelf(TId Value);
7 changes: 7 additions & 0 deletions src/StructId/Templates/Conversion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// <auto-generated />

readonly partial record struct Self
{
public static implicit operator string(Self id) => id.Value;
public static explicit operator Self(string value) => new(value);
}
7 changes: 7 additions & 0 deletions src/StructId/Templates/ConversionT.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// <auto-generated />

readonly partial record struct TSelf
{
public static implicit operator TId(TSelf id) => id.Value;
public static explicit operator TSelf(TId value) => new(value);
}
Loading

0 comments on commit b4dbdc0

Please sign in to comment.