Skip to content

Commit 8030671

Browse files
committed
Refactor code templates processing, move more to compiled
Refactor resource and compiled templates to use a unified CodeTemplate implementation that simplifies the application of a template for a given target struct id. This allowed to also simplify quite a bit the tests. Most templates are now simple compiled ones, except for the ones that need custom logic that isn't just checking target value type (TId) compatiblity. In particular: ctor generation is dynamic since users can provide their own ctor, so we can't just apply them as compiled templates. EF, Dapper and Newtonsoft.Json need conditional checking on type presense, so they cannot be ported either. Regardless, we now have a single unified way of authoring templates for either scenario. This infolves using always file-only types, which removes cross-template dependencies we had with a hardcoded TSelf/TId pair.
1 parent 8050bb1 commit 8030671

37 files changed

Lines changed: 712 additions & 537 deletions

src/StructId.Analyzer/AnalysisExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public static class AnalysisExtensions
2121
public static SymbolDisplayFormat FullNameNullable { get; } = new(
2222
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
2323
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
24-
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.ExpandNullable);
24+
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.ExpandNullable | SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
2525

2626
public static string ToFullName(this ISymbol symbol) => symbol.ToDisplayString(FullNameNullable);
2727

@@ -149,6 +149,9 @@ public static string ToFileName(this ITypeSymbol type)
149149

150150
public static bool IsStructId(this ITypeSymbol type) => type.AllInterfaces.Any(x => x.Name == "IStructId");
151151

152+
public static bool IsStructIdTemplate(this AttributeSyntax attribute)
153+
=> attribute.Name.ToString() == "TStructId" || attribute.Name.ToString() == "TStructIdAttribute";
154+
152155
public static bool IsPartial(this ITypeSymbol node) => node.DeclaringSyntaxReferences.Any(
153156
r => r.GetSyntax() is TypeDeclarationSyntax { Modifiers: { } modifiers } &&
154157
modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)));

src/StructId.Analyzer/BaseGenerator.cs

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public enum ReferenceCheck
2222

2323
public abstract class BaseGenerator(string referenceType, string stringTemplate, string typeTemplate, ReferenceCheck referenceCheck = ReferenceCheck.ValueIsType) : IIncrementalGenerator
2424
{
25+
SyntaxNode? stringSyntax;
26+
SyntaxNode? typedSyntax;
27+
2528
protected record struct TemplateArgs(string StructIdNamespace, INamedTypeSymbol StructId, INamedTypeSymbol ValueType, INamedTypeSymbol ReferenceType, INamedTypeSymbol StringType);
2629

2730
public virtual void Initialize(IncrementalGeneratorInitializationContext context)
@@ -67,39 +70,14 @@ public virtual void Initialize(IncrementalGeneratorInitializationContext context
6770

6871
void GenerateCode(SourceProductionContext context, TemplateArgs args) => AddFromTemplate(
6972
context, args, $"{args.StructId.ToFileName()}.cs",
70-
args.ValueType.Equals(args.StringType, SymbolEqualityComparer.Default) ? stringTemplate : typeTemplate);
73+
args.ValueType.Equals(args.StringType, SymbolEqualityComparer.Default) ?
74+
(stringSyntax ??= CodeTemplate.Parse(stringTemplate)) :
75+
(typedSyntax ??= CodeTemplate.Parse(typeTemplate)));
7176

72-
protected static void AddFromTemplate(SourceProductionContext context, TemplateArgs args, string hintName, string template)
77+
protected static void AddFromTemplate(SourceProductionContext context, TemplateArgs args, string hintName, SyntaxNode template)
7378
{
74-
var ns = args.StructId.ContainingNamespace.Equals(args.StructId.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default)
75-
? null
76-
: args.StructId.ContainingNamespace.ToDisplayString();
77-
78-
// replace tokens in the template
79-
var replaced = template
80-
// Adjust to current target namespace
81-
.Replace("namespace StructId;", $"namespace {args.StructIdNamespace};")
82-
.Replace("using StructId;", $"using {args.StructIdNamespace};")
83-
// Simple names suffices since we emit a partial in the same namespace
84-
.Replace("TSelf", args.StructId.Name)
85-
.Replace("Self", args.StructId.Name)
86-
.Replace("TId", args.ValueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
87-
88-
// parse template into a C# compilation unit
89-
var syntax = CSharpSyntaxTree.ParseText(replaced).GetCompilationUnitRoot();
90-
91-
// if we got a ns, move all members after a file-scoped namespace declaration
92-
if (ns != null)
93-
{
94-
var members = syntax.Members;
95-
var fsns = FileScopedNamespaceDeclaration(ParseName(ns).WithLeadingTrivia(Whitespace(" ")))
96-
.WithLeadingTrivia(LineFeed)
97-
.WithTrailingTrivia(LineFeed)
98-
.WithMembers(members);
99-
syntax = syntax.WithMembers(SingletonList<MemberDeclarationSyntax>(fsns));
100-
}
101-
102-
var output = syntax.ToFullString();
79+
var applied = template.Apply(args.StructId);
80+
var output = applied.ToFullString();
10381

10482
context.AddSource(hintName, SourceText.From(output, Encoding.UTF8));
10583
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
7+
8+
namespace StructId;
9+
10+
public static class CodeTemplate
11+
{
12+
public static SyntaxNode Parse(string template)
13+
{
14+
var tree = CSharpSyntaxTree.ParseText(template,
15+
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest));
16+
17+
return tree.GetRoot();
18+
}
19+
20+
public static string Apply(string template, string structIdType, string valueType)
21+
{
22+
var targetNamespace = structIdType.Contains('.') ? structIdType.Substring(0, structIdType.LastIndexOf('.')) : null;
23+
structIdType = structIdType.Contains('.') ? structIdType.Substring(structIdType.LastIndexOf('.') + 1) : structIdType;
24+
25+
return ApplyImpl(Parse(template), structIdType, valueType, targetNamespace).ToFullString();
26+
}
27+
28+
public static SyntaxNode Apply(this SyntaxNode node, INamedTypeSymbol structId)
29+
{
30+
var root = node.SyntaxTree.GetCompilationUnitRoot();
31+
if (root == null)
32+
return node;
33+
34+
// determine namespace of the IStructId/IStructId<T> interface implemented by structId
35+
var iface = structId.Interfaces.FirstOrDefault(x => x.Name == "IStructId");
36+
if (iface == null)
37+
return root;
38+
39+
var tid = iface.TypeArguments.FirstOrDefault()?.ToFullName() ?? "string";
40+
var corens = iface.ContainingNamespace.ToFullName();
41+
var targetNamespace = structId.ContainingNamespace != null && !structId.ContainingNamespace.IsGlobalNamespace ?
42+
structId.ContainingNamespace.ToDisplayString() : null;
43+
44+
return ApplyImpl(root, structId.Name, tid, targetNamespace, corens);
45+
}
46+
47+
static SyntaxNode ApplyImpl(this SyntaxNode node, string structIdType, string valueType, string? targetNamespace = default, string coreNamespace = "StructId")
48+
{
49+
var root = node.SyntaxTree.GetCompilationUnitRoot();
50+
if (root == null)
51+
return node;
52+
53+
// If we got a ns, move all members after a file-scoped namespace declaration
54+
if (targetNamespace != null)
55+
{
56+
var members = root.Members;
57+
var fsns = FileScopedNamespaceDeclaration(ParseName(targetNamespace)
58+
.WithLeadingTrivia(node.GetLeadingTrivia())
59+
.WithLeadingTrivia(Whitespace(" ")))
60+
.WithLeadingTrivia(LineFeed)
61+
.WithTrailingTrivia(LineFeed, LineFeed)
62+
.WithMembers(members);
63+
64+
root = root.WithMembers(SingletonList<MemberDeclarationSyntax>(fsns));
65+
}
66+
67+
var usings = root.DescendantNodes().OfType<UsingDirectiveSyntax>().ToList();
68+
// There should be NO namespace declared in the template itself, since we enforce file-local
69+
usings.Add(UsingDirective(ParseName(coreNamespace)).NormalizeWhitespace());
70+
71+
// deduplicate usings just in case
72+
var unique = new HashSet<string>();
73+
root = root.ReplaceNodes(usings, (old, _) =>
74+
{
75+
// replace 'StructId' > StructIdNamespace
76+
if (old.Name?.ToString() == "StructId")
77+
{
78+
unique.Add(coreNamespace);
79+
return old.WithName(ParseName(coreNamespace));
80+
}
81+
82+
if (unique.Add(old.Name?.ToString() ?? ""))
83+
return old;
84+
85+
return null!;
86+
});
87+
88+
node = new TemplateRewriter(structIdType, valueType).Visit(root)!;
89+
90+
return node;
91+
}
92+
93+
class TemplateRewriter(string tself, string tid) : CSharpSyntaxRewriter
94+
{
95+
public override SyntaxNode? VisitRecordDeclaration(RecordDeclarationSyntax node)
96+
{
97+
// remove file-local records that aren't annotated with [TStructId]
98+
if (node.Modifiers.Any(x => x.IsKind(SyntaxKind.FileKeyword)) &&
99+
!node.AttributeLists.Any(list => list.Attributes.Any(a => a.IsStructIdTemplate())))
100+
return null;
101+
102+
// If the record has the [TStructId] attribute, remove parameter list
103+
if (node.AttributeLists.Any(list => list.Attributes.Any(a => a.IsStructIdTemplate())) &&
104+
node.ParameterList is { } parameters)
105+
{
106+
// Check if the open paren trivia contains the text '🙏' and remove it
107+
if (parameters.OpenParenToken.GetAllTrivia().Any(x => x.ToString().Contains("🙏")))
108+
node = node.WithParameterList(parameters
109+
.WithOpenParenToken(parameters.OpenParenToken.WithoutTrivia()));
110+
else
111+
node = node.WithParameterList(null);
112+
}
113+
114+
var visited = (RecordDeclarationSyntax)base.VisitRecordDeclaration(node)!;
115+
116+
// remove file modifier from type declarations
117+
if (visited.Modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.FileKeyword)) is { } file)
118+
// Preserve trivia, i.e. newline from original file modifier
119+
return visited
120+
.WithLeadingTrivia(file.LeadingTrivia)
121+
.WithModifiers(visited.Modifiers.Remove(file));
122+
123+
return visited;
124+
}
125+
126+
public override SyntaxNode? VisitStructDeclaration(StructDeclarationSyntax node)
127+
{
128+
// remove file-local structs that aren't annotated with [TStructId]
129+
if (node.Modifiers.Any(x => x.IsKind(SyntaxKind.FileKeyword)) &&
130+
!node.AttributeLists.Any(list => list.Attributes.Any(a => a.IsStructIdTemplate())))
131+
return null;
132+
133+
return base.VisitStructDeclaration(node);
134+
}
135+
136+
public override SyntaxNode? VisitAttributeList(AttributeListSyntax node)
137+
{
138+
node = (AttributeListSyntax)base.VisitAttributeList(node)!;
139+
if (node.Attributes.Count == 0)
140+
return null;
141+
142+
return node;
143+
}
144+
145+
public override SyntaxNode? VisitAttribute(AttributeSyntax node)
146+
{
147+
if (node.IsStructIdTemplate())
148+
return null;
149+
150+
return base.VisitAttribute(node);
151+
}
152+
153+
// rewrite references to the original type with the target type
154+
public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node)
155+
{
156+
if (node.Identifier.Text == "TSelf")
157+
return IdentifierName(tself)
158+
.WithLeadingTrivia(node.Identifier.LeadingTrivia)
159+
.WithTrailingTrivia(node.Identifier.TrailingTrivia);
160+
else if (node.Identifier.Text == "TId")
161+
return IdentifierName(tid)
162+
.WithLeadingTrivia(node.Identifier.LeadingTrivia)
163+
.WithTrailingTrivia(node.Identifier.TrailingTrivia);
164+
165+
return base.VisitIdentifierName(node);
166+
}
167+
168+
public override SyntaxToken VisitToken(SyntaxToken token)
169+
{
170+
// if token is an identifier token, rewrite it
171+
if (token.IsKind(SyntaxKind.IdentifierToken) && token.Text == "TSelf")
172+
return Identifier(tself)
173+
.WithLeadingTrivia(token.LeadingTrivia)
174+
.WithTrailingTrivia(token.TrailingTrivia);
175+
else if (token.IsKind(SyntaxKind.IdentifierToken) && token.Text == "TId")
176+
return Identifier(tid)
177+
.WithLeadingTrivia(token.LeadingTrivia)
178+
.WithTrailingTrivia(token.TrailingTrivia);
179+
180+
return base.VisitToken(token);
181+
}
182+
}
183+
}

src/StructId.Analyzer/DapperGenerator.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ protected override IncrementalValuesProvider<TemplateArgs> OnInitialize(Incremen
2020
"System.Guid" => true,
2121
"System.Int32" => true,
2222
"System.Int64" => true,
23+
"string" => true,
24+
"int" => true,
25+
"long" => true,
2326
_ => false
2427
});
2528

src/StructId.Analyzer/NewableGenerator.cs

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/StructId.Analyzer/ParsableGenerator.cs

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/StructId.Analyzer/StructId.Analyzer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
<ItemGroup>
2323
<TemplateCode Include="..\StructId\ResourceTemplates\*.cs" Link="StructId\%(Filename)%(Extension)" />
24+
<TemplateCode Include="..\StructId\Templates\*.cs" Link="StructId\%(Filename)%(Extension)" />
2425
<EmbeddedResource Include="*.sbn" Kind="Text" />
2526
<UpToDateCheck Include="@(TemplateCode);@(EmbeddedResource)" />
2627
</ItemGroup>

0 commit comments

Comments
 (0)