Skip to content

Commit 84be9a3

Browse files
committed
Refactor ctor and implicit conversions as template-based
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.
1 parent c247fa0 commit 84be9a3

File tree

14 files changed

+273
-55
lines changed

14 files changed

+273
-55
lines changed

readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ be offered to correct it.
5858
The relevant constructor and `Value` property will be generated for you, as well as
5959
as a few other common interfaces, such as `IComparable<T>`, `IParsable<TSelf>`, etc.
6060

61+
If you want to customize the primary constructor (i.e. to add custom attributes),
62+
you can provide it yourself too:
63+
64+
```csharp
65+
public readonly partial record struct ProductId(int Value) : IStructId<int>;
66+
```
67+
68+
It must contain a single parameter named `Value` (and codefixes will offer to rename or
69+
remove it if you don't need it anymore).
70+
6171
### EF Core
6272

6373
If you are using EF Core, the package will automatically generate the necessary value converters,
Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,17 @@
11
using System.Linq;
2-
using System.Text;
32
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
44

55
namespace StructId;
66

77
[Generator(LanguageNames.CSharp)]
8-
public class ConstructorGenerator : IIncrementalGenerator
8+
public class ConstructorGenerator() : TemplateGenerator(
9+
"System.Object",
10+
ThisAssembly.Resources.Templates.Constructor.Text,
11+
ThisAssembly.Resources.Templates.ConstructorT.Text,
12+
ReferenceCheck.TypeExists)
913
{
10-
public void Initialize(IncrementalGeneratorInitializationContext context)
11-
{
12-
var ids = context.CompilationProvider
13-
.SelectMany((x, _) => x.Assembly.GetAllTypes().OfType<INamedTypeSymbol>())
14-
.Where(t => t.IsStructId())
15-
.Where(t => t.IsPartial());
16-
17-
context.RegisterSourceOutput(ids, GenerateCode);
18-
}
19-
20-
void GenerateCode(SourceProductionContext context, INamedTypeSymbol symbol)
21-
{
22-
var ns = symbol.ContainingNamespace.Equals(symbol.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default)
23-
? null
24-
: symbol.ContainingNamespace.ToDisplayString();
25-
26-
// Generic IStructId<T> -> T, otherwise string
27-
var type = symbol.AllInterfaces.First(x => x.Name == "IStructId").TypeArguments.Select(x => x.GetTypeName(ns)).FirstOrDefault() ?? "string";
28-
29-
var kind = symbol.IsRecord && symbol.IsValueType ?
30-
"record struct" :
31-
symbol.IsRecord ?
32-
"record" :
33-
"class";
34-
35-
var output = new StringBuilder();
36-
37-
output.AppendLine("// <auto-generated/>");
38-
if (ns != null)
39-
output.AppendLine($"namespace {ns};");
40-
41-
output.AppendLine(
42-
$$"""
43-
44-
[System.CodeDom.Compiler.GeneratedCode("StructId", "{{ThisAssembly.Info.InformationalVersion}}")]
45-
partial {{kind}} {{symbol.Name}}({{type}} Value)
46-
{
47-
public static implicit operator {{type}}({{symbol.Name}} id) => id.Value;
48-
public static explicit operator {{symbol.Name}}({{type}} value) => new(value);
49-
}
50-
""");
51-
52-
context.AddSource($"{symbol.ToFileName()}.cs", output.ToString());
53-
}
54-
}
14+
protected override IncrementalValuesProvider<TemplateArgs> OnInitialize(IncrementalGeneratorInitializationContext context, IncrementalValuesProvider<TemplateArgs> source)
15+
=> base.OnInitialize(context, source.Where(x
16+
=> x.StructId.DeclaringSyntaxReferences.Select(r => r.GetSyntax()).OfType<TypeDeclarationSyntax>().All(s => s.ParameterList == null)));
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace StructId;
4+
5+
[Generator(LanguageNames.CSharp)]
6+
public class ConversionGenerator() : TemplateGenerator(
7+
"System.Object",
8+
ThisAssembly.Resources.Templates.Conversion.Text,
9+
ThisAssembly.Resources.Templates.ConversionT.Text,
10+
ReferenceCheck.TypeExists);

src/StructId.Analyzer/Diagnostics.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ namespace StructId;
44

55
public static class Diagnostics
66
{
7-
/// <summary>
8-
/// SID001: StructId must be a partial readonly record struct.
9-
/// </summary>
107
public static DiagnosticDescriptor MustBeRecordStruct { get; } = new(
118
"SID001",
129
"Struct ids must be partial readonly record structs",
@@ -15,4 +12,13 @@ public static class Diagnostics
1512
DiagnosticSeverity.Error,
1613
isEnabledByDefault: true,
1714
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID001.md");
18-
}
15+
16+
public static DiagnosticDescriptor MustHaveValueConstructor { get; } = new(
17+
"SID002",
18+
"Struct id custom constructor must provide a single Value parameter",
19+
"Custom constructor for '{0}' must have a Value parameter",
20+
"Build",
21+
DiagnosticSeverity.Error,
22+
isEnabledByDefault: true,
23+
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID002.md");
24+
}

src/StructId.Analyzer/RecordAnalyzer.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Immutable;
2+
using System.Diagnostics;
23
using System.Linq;
34
using Microsoft.CodeAnalysis;
45
using Microsoft.CodeAnalysis.CSharp;
@@ -12,12 +13,15 @@ namespace StructId;
1213
public class RecordAnalyzer : DiagnosticAnalyzer
1314
{
1415
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
15-
=> ImmutableArray.Create(MustBeRecordStruct);
16+
=> ImmutableArray.Create(MustBeRecordStruct, MustHaveValueConstructor);
1617

1718
public override void Initialize(AnalysisContext context)
1819
{
1920
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
20-
context.EnableConcurrentExecution();
21+
22+
if (!Debugger.IsAttached)
23+
context.EnableConcurrentExecution();
24+
2125
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration);
2226
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.StructDeclaration);
2327
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.RecordDeclaration);
@@ -53,5 +57,21 @@ static void Analyze(SyntaxNodeAnalysisContext context)
5357
else
5458
context.ReportDiagnostic(Diagnostic.Create(MustBeRecordStruct, typeDeclaration.Identifier.GetLocation(), symbol.Name));
5559
}
60+
61+
if (typeDeclaration.ParameterList is null)
62+
return;
63+
64+
// If there are parameters, it must be only one, be named Value and be either
65+
// type string (if implementing IStructId) or the TId (if implementing IStructId<TId>)
66+
if (typeDeclaration.ParameterList.Parameters.Count != 1)
67+
{
68+
context.ReportDiagnostic(Diagnostic.Create(MustHaveValueConstructor, typeDeclaration.ParameterList.GetLocation(), symbol.Name));
69+
return;
70+
}
71+
72+
var parameter = typeDeclaration.ParameterList.Parameters[0];
73+
if (parameter.Identifier.Text != "Value")
74+
context.ReportDiagnostic(Diagnostic.Create(MustHaveValueConstructor, parameter.Identifier.GetLocation(), symbol.Name));
75+
5676
}
5777
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CodeActions;
7+
using Microsoft.CodeAnalysis.CodeFixes;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
10+
11+
namespace StructId;
12+
13+
[Shared]
14+
[ExportCodeFixProvider(LanguageNames.CSharp)]
15+
public class RemoveCtorCodeFix : CodeFixProvider
16+
{
17+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(Diagnostics.MustHaveValueConstructor.Id);
18+
19+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
20+
21+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
22+
{
23+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
24+
if (root == null)
25+
return;
26+
27+
var declaration = root.FindNode(context.Span).FirstAncestorOrSelf<TypeDeclarationSyntax>();
28+
if (declaration == null)
29+
return;
30+
31+
if (declaration.ParameterList?.Parameters.Count == 1)
32+
context.RegisterCodeFix(
33+
new RemoveAction(context.Document, root, declaration),
34+
context.Diagnostics);
35+
}
36+
37+
public class RemoveAction(Document document, SyntaxNode root, TypeDeclarationSyntax declaration) : CodeAction
38+
{
39+
public override string Title => "Remove primary constructor to generate it automatically";
40+
public override string EquivalenceKey => Title;
41+
42+
protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
43+
{
44+
return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(declaration,
45+
declaration.WithParameterList(null))));
46+
}
47+
}
48+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CodeActions;
7+
using Microsoft.CodeAnalysis.CodeFixes;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
10+
11+
namespace StructId;
12+
13+
[Shared]
14+
[ExportCodeFixProvider(LanguageNames.CSharp)]
15+
public class RenameCtorCodeFix : CodeFixProvider
16+
{
17+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(Diagnostics.MustHaveValueConstructor.Id);
18+
19+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
20+
21+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
22+
{
23+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
24+
if (root == null)
25+
return;
26+
27+
var parameter = root.FindNode(context.Span).FirstAncestorOrSelf<ParameterSyntax>();
28+
if (parameter == null)
29+
return;
30+
31+
context.RegisterCodeFix(
32+
new RenameAction(context.Document, root, parameter),
33+
context.Diagnostics);
34+
}
35+
36+
public class RenameAction(Document document, SyntaxNode root, ParameterSyntax parameter) : CodeAction
37+
{
38+
public override string Title => "Rename to 'Value' as required for struct ids";
39+
public override string EquivalenceKey => Title;
40+
41+
protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
42+
{
43+
return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(parameter,
44+
parameter.WithIdentifier(Identifier("Value")))));
45+
}
46+
}
47+
}

src/StructId.Tests/RecordAnalyzerTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,24 @@ public class UserId : {|#0:IStructId<int>|};
184184

185185
await test.RunAsync();
186186
}
187+
188+
[Fact]
189+
public async Task ReadonlyRecordStructWithNonValueConstructor()
190+
{
191+
var test = new Test
192+
{
193+
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
194+
TestCode =
195+
"""
196+
using StructId;
197+
198+
public readonly partial record struct UserId(int {|#0:value|}) : IStructId<int>;
199+
""",
200+
}.WithAnalyzerStructId();
201+
202+
test.ExpectedDiagnostics.Add(Verifier.Diagnostic(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
203+
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(3, 59));
204+
205+
await test.RunAsync();
206+
}
187207
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CSharp.Testing;
8+
using Microsoft.CodeAnalysis.Testing;
9+
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<StructId.RecordAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
10+
11+
namespace StructId;
12+
13+
public class RecordCtorCodeFixTests
14+
{
15+
[Fact]
16+
public async Task RenameValue()
17+
{
18+
var test = new CSharpCodeFixTest<RecordAnalyzer, RenameCtorCodeFix, DefaultVerifier>
19+
{
20+
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
21+
TestCode =
22+
"""
23+
using StructId;
24+
25+
public readonly partial record struct UserId(int {|#0:foo|}) : {|#1:IStructId<int>|};
26+
""",
27+
FixedCode =
28+
"""
29+
using StructId;
30+
31+
public readonly partial record struct UserId(int Value) : IStructId<int>;
32+
""",
33+
}.WithCodeFixStructId();
34+
35+
test.ExpectedDiagnostics.Add(new DiagnosticResult(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
36+
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(1));
37+
38+
// Don't propagate the expected diagnostics to the fixed code, it will have none of them
39+
test.FixedState.InheritanceMode = StateInheritanceMode.Explicit;
40+
41+
await test.RunAsync();
42+
}
43+
44+
[Fact]
45+
public async Task RemoveCtor()
46+
{
47+
var test = new CSharpCodeFixTest<RecordAnalyzer, RemoveCtorCodeFix, DefaultVerifier>
48+
{
49+
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
50+
TestCode =
51+
"""
52+
using StructId;
53+
54+
public readonly partial record struct UserId(int {|#0:foo|}) : {|#1:IStructId<int>|};
55+
""",
56+
FixedCode =
57+
"""
58+
using StructId;
59+
60+
public readonly partial record struct UserId: {|#0:IStructId<int>|};
61+
""",
62+
}.WithCodeFixStructId();
63+
64+
test.ExpectedDiagnostics.Add(new DiagnosticResult(Diagnostics.MustHaveValueConstructor).WithLocation(0).WithArguments("UserId"));
65+
test.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(1));
66+
67+
// Don't propagate the expected diagnostics to the fixed code, it will have none of them
68+
test.FixedState.InheritanceMode = StateInheritanceMode.Explicit;
69+
test.FixedState.ExpectedDiagnostics.Add(new DiagnosticResult("CS0535", DiagnosticSeverity.Error).WithLocation(0));
70+
71+
await test.RunAsync();
72+
}
73+
74+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// <auto-generated />
2+
3+
readonly partial record struct Self(string Value);

0 commit comments

Comments
 (0)