Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create analyzer and codefix for templates #35

Merged
merged 1 commit into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading