Skip to content

Commit

Permalink
Create analyzer and codefix for templates
Browse files Browse the repository at this point in the history
Templates need to adhere to some minimal rules:
1. File-local visibility and partial
2. Record struct themselves (since they will be partial of another record struct)
3. Optional primary ctor with Value parameter to allow template code to operate on the underlying value of the struct id as needed.

We now enforce these rules via an analyzer and provide a codefix to help authoring.
  • Loading branch information
kzu committed Dec 9, 2024
1 parent c4361e7 commit 41a0285
Show file tree
Hide file tree
Showing 8 changed files with 595 additions and 8 deletions.
21 changes: 18 additions & 3 deletions src/StructId.Analyzer/CodeTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ public static SyntaxNode Parse(string template)
return tree.GetRoot();
}

public static string Apply(string template, string structIdType, string valueType)
public static string Apply(string template, string structIdType, string valueType, bool normalizeWhitespace = false)
{
var targetNamespace = structIdType.Contains('.') ? structIdType.Substring(0, structIdType.LastIndexOf('.')) : null;
structIdType = structIdType.Contains('.') ? structIdType.Substring(structIdType.LastIndexOf('.') + 1) : structIdType;

return ApplyImpl(Parse(template), structIdType, valueType, targetNamespace).ToFullString();
var applied = ApplyImpl(Parse(template), structIdType, valueType, targetNamespace);

return normalizeWhitespace ?
applied.NormalizeWhitespace().ToFullString() :
applied.ToFullString();
}

public static SyntaxNode Apply(this SyntaxNode node, INamedTypeSymbol structId)
Expand Down Expand Up @@ -99,11 +103,13 @@ class TemplateRewriter(string tself, string tid) : CSharpSyntaxRewriter
!node.AttributeLists.Any(list => list.Attributes.Any(a => a.IsStructIdTemplate())))
return null;

// If the record has the [TStructId] attribute, remove parameter list
// If the record has the [TStructId] attribute, remove primary ctor
if (node.AttributeLists.Any(list => list.Attributes.Any(a => a.IsStructIdTemplate())) &&
node.ParameterList is { } parameters)
{
// Check if the open paren trivia contains the text '🙏' and remove it
// This is used to signal that the primary ctor should not be removed.
// This is the case with the ctor templates.
if (parameters.OpenParenToken.GetAllTrivia().Any(x => x.ToString().Contains("🙏")))
node = node.WithParameterList(parameters
.WithOpenParenToken(parameters.OpenParenToken.WithoutTrivia()));
Expand Down Expand Up @@ -133,6 +139,15 @@ class TemplateRewriter(string tself, string tid) : CSharpSyntaxRewriter
return base.VisitStructDeclaration(node);
}

public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node)
{
// remove file-local classes (they can't be annotated with [TStructId])
if (node.Modifiers.Any(x => x.IsKind(SyntaxKind.FileKeyword)))
return null;

return base.VisitClassDeclaration(node);
}

public override SyntaxNode? VisitAttributeList(AttributeListSyntax node)
{
node = (AttributeListSyntax)base.VisitAttributeList(node)!;
Expand Down
33 changes: 30 additions & 3 deletions src/StructId.Analyzer/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,46 @@ public static class Diagnostics
{
public static DiagnosticDescriptor MustBeRecordStruct { get; } = new(
"SID001",
"Struct ids must be partial readonly record structs",
"Change '{0}' to a partial readonly record struct as required for types used as struct ids.",
"Struct Ids must be partial readonly record structs",
"'{0}' must be a partial readonly record struct to be a struct ids.",
"Build",
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",
"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");

public static DiagnosticDescriptor TemplateMustBeFileRecordStruct { get; } = new(
"SID003",
"Struct Id templates must be file-local partial record structs",
"'{0}' must be a file-local partial record struct to be used as a template.",
"Build",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID003.md");

public static DiagnosticDescriptor TemplateConstructorValueConstructor { get; } = new(
"SID004",
"Struct Id template constructor must provide a single Value parameter",
"Custom template constructor must have a single Value parameter, if present",
"Build",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID004.md");

public static DiagnosticDescriptor TemplateDeclarationNotTSelf { get; } = new(
"SID005",
"Struct Id template declaration must use the reserved name 'TSelf'",
"'{0}' must be named 'TSelf' to be used as a template.",
"Build",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: $"{ThisAssembly.Project.RepositoryUrl}/blob/{ThisAssembly.Project.RepositoryBranch}/docs/SID005.md");
}
61 changes: 61 additions & 0 deletions src/StructId.Analyzer/TemplateAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using static StructId.Diagnostics;

namespace StructId;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class TemplateAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(TemplateMustBeFileRecordStruct, TemplateConstructorValueConstructor, TemplateDeclarationNotTSelf);

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

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

context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.StructDeclaration);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.RecordDeclaration);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.RecordStructDeclaration);
}

static void Analyze(SyntaxNodeAnalysisContext context)
{
var ns = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.GetStructIdNamespace();

if (context.Node is not TypeDeclarationSyntax typeDeclaration ||
!typeDeclaration.AttributeLists.Any(list => list.Attributes.Any(attr => attr.IsStructIdTemplate())))
return;

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

if (!symbol.IsFileLocal || !symbol.IsPartial() || !typeDeclaration.IsKind(SyntaxKind.RecordStructDeclaration))
{
context.ReportDiagnostic(Diagnostic.Create(TemplateMustBeFileRecordStruct, typeDeclaration.Identifier.GetLocation(), symbol.Name));
}

// If there are parameters, it must be only one, and be named Value
if (typeDeclaration.ParameterList is { } parameters)
{

if (typeDeclaration.ParameterList.Parameters.Count != 1)
context.ReportDiagnostic(Diagnostic.Create(TemplateConstructorValueConstructor, typeDeclaration.ParameterList.GetLocation(), symbol.Name));
else if (typeDeclaration.ParameterList.Parameters[0].Identifier.Text != "Value")
context.ReportDiagnostic(Diagnostic.Create(TemplateConstructorValueConstructor, typeDeclaration.ParameterList.Parameters[0].Identifier.GetLocation(), symbol.Name));
}

if (typeDeclaration.Identifier.Text != "TSelf")
context.ReportDiagnostic(Diagnostic.Create(TemplateDeclarationNotTSelf, typeDeclaration.Identifier.GetLocation(), symbol.Name));
}
}
6 changes: 4 additions & 2 deletions src/StructId.CodeFix/RenameCtorCodeFix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using static StructId.Diagnostics;

namespace StructId;

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

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

Expand All @@ -35,7 +37,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)

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 Title => "Rename to 'Value'";
public override string EquivalenceKey => Title;

protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
Expand Down
93 changes: 93 additions & 0 deletions src/StructId.CodeFix/TemplateCodeFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using static StructId.Diagnostics;

namespace StructId;

[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public class TemplateCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(
TemplateMustBeFileRecordStruct.Id, TemplateDeclarationNotTSelf.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;

context.RegisterCodeFix(
new FixerAction(context.Document, root, declaration),
context.Diagnostics);
}

public class FixerAction(Document document, SyntaxNode root, TypeDeclarationSyntax original) : CodeAction
{
public override string Title => "Change to file-local partial record struct";
public override string EquivalenceKey => Title;

protected override Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
{
var declaration = original;
var modifiers = declaration.Modifiers;

if (!modifiers.Any(SyntaxKind.FileKeyword))
modifiers = modifiers.Insert(0, Token(SyntaxKind.FileKeyword));

if (!modifiers.Any(SyntaxKind.PartialKeyword))
modifiers = modifiers.Insert(1, Token(SyntaxKind.PartialKeyword));

// Remove accessibility modifiers which are replaced by 'file' visibility
if (modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.PublicKeyword)) is { } @public)
modifiers = modifiers.Remove(@public);
if (modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.InternalKeyword)) is { } @internal)
modifiers = modifiers.Remove(@internal);
if (modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.PrivateKeyword)) is { } @private)
modifiers = modifiers.Remove(@private);

if (declaration.Identifier.Text != "TSelf")
declaration = declaration.WithIdentifier(Identifier("TSelf"));

if (!declaration.IsKind(SyntaxKind.RecordStructDeclaration))
{
declaration = RecordDeclaration(
SyntaxKind.RecordStructDeclaration,
declaration.AttributeLists,
modifiers,
Token(SyntaxKind.RecordKeyword),
Token(SyntaxKind.StructKeyword),
declaration.Identifier,
declaration.TypeParameterList,
declaration.ParameterList,
declaration.BaseList,
declaration.ConstraintClauses,
declaration.OpenBraceToken,
declaration.Members,
declaration.CloseBraceToken,
declaration.SemicolonToken);
}
else if (modifiers != declaration.Modifiers)
{
declaration = declaration.WithModifiers(modifiers);
}

return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(original, declaration)));
}
}
}
35 changes: 35 additions & 0 deletions src/StructId.Tests/CodeTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,39 @@ partial record struct UserId(string Value);
""").NormalizeWhitespace().ToFullString().Trim().ReplaceLineEndings(),
applied.ReplaceLineEndings());
}

[Fact]
public void RemovesFileLocalTypes()
{
var template =
"""
using StructId;
[TStructId]
file partial record struct TSelf
{
// From template
}
file record TSome;
file class TAnother;
file record struct TYetAnother;
""";

var applied = CodeTemplate.Apply(template, "Foo", "string", normalizeWhitespace: true);

output.WriteLine(applied);

Assert.Equal(
CodeTemplate.Parse(
"""
using StructId;
partial record struct Foo
{
// From template
}
""").NormalizeWhitespace().ToFullString().Trim().ReplaceLineEndings(),
applied.ReplaceLineEndings());
}
}
Loading

0 comments on commit 41a0285

Please sign in to comment.