Skip to content

Commit

Permalink
Initial user templates support
Browse files Browse the repository at this point in the history
Templates are regular C# code, annotated with `[StructId]`.

The template must provide the `[TId] Value` primary constructor, so that operations for the provided members can use the value just as it will for generated `IStructId` implementations. The type of the value determines the target ids that will get the template applied: they must match the TId type.

The provided functional sample showcases how it works. Say you have a custom interface in your app, `IId` which you use to retrieve a GUID that represents the ID of your domain objects. You can have all struct ids that have a TId of Guid implement this interface by defining the template as:

```csharp
[TStructId]
file partial record struct IIdTemplate(Guid Value) : IId
{
    public Guid Id => Value;
}
```

What happens at compile-time:
1. The attribute triggers template processing for this type
2. The `Guid Value` parameter is used to filter struct ids that implement `IStructId<Guid>` (which will therefore have a matching constructor and value type).
3. The emitted partial is added to whichever namespace the target ID is, so the template does not need a namespace at all (should use global namespace).
4. The `file` keyword is used so that this type does not pollute the API surface of the project containing the template.

Templates can come from referenced projects too, as long as the generator can locate the original syntax tree for it (to apply it to the target id).
  • Loading branch information
kzu committed Nov 30, 2024
1 parent 17360d5 commit 24f3a62
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 1 deletion.
174 changes: 174 additions & 0 deletions src/StructId.Analyzer/TemplatedGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace StructId;

[Generator(LanguageNames.CSharp)]
public class TemplatedGenerator : IIncrementalGenerator
{
record KnownTypes(INamedTypeSymbol String, INamedTypeSymbol? IStructId, INamedTypeSymbol? TStructId, INamedTypeSymbol? TStructIdT);
record IdTemplate(INamedTypeSymbol StructId, Template Template);
record Template(INamedTypeSymbol TSelf, ITypeSymbol TId, AttributeData Attribute, bool IsGenericTId)
{
public Regex NameExpr { get; } = new Regex($@"\b{TSelf.Name}\b", RegexOptions.Compiled | RegexOptions.Multiline);

public string Text { get; } = GetTemplateCode(TSelf, TId, Attribute);

static string GetTemplateCode(INamedTypeSymbol self, ITypeSymbol tid, AttributeData attribute)
{
if (self.DeclaringSyntaxReferences[0].GetSyntax() is not TypeDeclarationSyntax declaration)
return "";

// Remove the TId/TValue if present in the same syntax tree.
var toremove = tid.DeclaringSyntaxReferences.Select(x => x.GetSyntax()).ToList();
// Also the [TStructId<T>] attribute applied to the template itself
if (attribute.ApplicationSyntaxReference?.GetSyntax().FirstAncestorOrSelf<AttributeListSyntax>() is { } attr)
toremove.Add(attr);
// And the primary constructor if present, since that's generated for the struct id already
if (declaration.ParameterList != null)
toremove.Add(declaration.ParameterList);

var root = declaration.SyntaxTree.GetRoot()
.RemoveNodes(toremove, SyntaxRemoveOptions.KeepLeadingTrivia)!;

var update = root.DescendantNodes().OfType<TypeDeclarationSyntax>().First(x => x.Identifier.Text == self.Name);

// Remove file-scoped modifier if present
if (update.Modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.FileKeyword)) is { } file)
{
var updated = update.WithModifiers(update.Modifiers.Remove(file));
// Preserve trivia, i.e. newline from original file modifier
if (updated.Modifiers.Count > 0)
updated = updated.ReplaceToken(updated.Modifiers[0], updated.Modifiers[0].WithLeadingTrivia(file.LeadingTrivia));

root = root.ReplaceNode(update, updated);
}

return root.SyntaxTree.GetRoot().ToFullString().Trim();
}
}

public void Initialize(IncrementalGeneratorInitializationContext context)
{
var targetNamespace = context.AnalyzerConfigOptionsProvider
.Select((x, _) => x.GlobalOptions.TryGetValue("build_property.StructIdNamespace", out var ns) ? ns : "StructId");

var known = context.CompilationProvider
.Combine(targetNamespace)
.Select((x, _) => new KnownTypes(
// get string known type
x.Left.GetTypeByMetadataName("System.String")!,
x.Left.GetTypeByMetadataName($"{x.Right}.IStructId`1"),
x.Left.GetTypeByMetadataName($"{x.Right}.TStructIdAttribute"),
x.Left.GetTypeByMetadataName($"{x.Right}.TStructIdAttribute`1")));

var templates = context.CompilationProvider
.SelectMany((x, _) => x.GetAllTypes(includeReferenced: true).OfType<INamedTypeSymbol>())
.Combine(known)
.Where(x =>
// Ensure template is a partial record struct
x.Left.TypeKind == TypeKind.Struct && x.Left.IsRecord &&
// We can only work with templates where we have the actual syntax tree.
x.Left.DeclaringSyntaxReferences.Length == 1 &&
// The declaring syntax reference has a primary constructor with a single parameter named Value
// This would be enforced by an analyzer/codefix pair.
x.Left.DeclaringSyntaxReferences[0].GetSyntax() is TypeDeclarationSyntax declaration &&
declaration.ParameterList?.Parameters.Count == 1 &&
declaration.ParameterList.Parameters[0].Identifier.Text == "Value" &&
// And we can locate the TStructIdAttribute type that should be applied to it.
x.Right.TStructId != null && x.Right.TStructIdT != null &&
x.Left.GetAttributes().Any(a => a.AttributeClass != null &&
// The attribute should either be the generic or regular TStructIdAttribute
(a.AttributeClass.Is(x.Right.TStructId) || a.AttributeClass.Is(x.Right.TStructIdT))))
.Select((x, _) =>
{
var (structId, known) = x;
var attribute = structId.GetAttributes().FirstOrDefault(a => a.AttributeClass != null && a.AttributeClass.Is(known.TStructIdT));
if (attribute != null)
return new Template(structId, attribute.AttributeClass!.TypeArguments[0], attribute, true);
// If we don't have the generic attribute, infer the idType from the required
// primary constructor Value parameter type
var idType = structId.GetMembers().OfType<IPropertySymbol>().First(p => p.Name == "Value").Type;
attribute = structId.GetAttributes().First(a => a.AttributeClass != null && a.AttributeClass.Is(known.TStructId));
return new Template(structId, idType, attribute, false);
})
.Collect();

var ids = context.CompilationProvider
.SelectMany((x, _) => x.Assembly.GetAllTypes().OfType<INamedTypeSymbol>())
.Combine(known)
.Where(x => x.Right.IStructId != null && x.Left.Is(x.Right.IStructId) && x.Left.IsPartial())
.Combine(templates)
.Where(x =>
{
var ((id, known), templates) = x;
var structId = id.AllInterfaces.FirstOrDefault(i => i.Is(known.IStructId));
return structId != null;
})
.SelectMany((x, _) =>
{
var ((id, known), templates) = x;
// Locate the IStructId<TId> interface implemented by the id
var structId = id.AllInterfaces.First(i => i.Is(known.IStructId));
var tid = structId.TypeArguments[0];
// If the current struct id (which will be a generic) implements or inherits from
// the template base type and/or its interfaces
return templates
// check struct id's value type against the template's TId for compatibility
.Where(template =>
tid.Equals(template.TId, SymbolEqualityComparer.Default) ||
tid.Is(template.TId) ||
// If the template had a generic attribute, we'd be looking at an intermediate
// type (typically TValue or TId) being used to define multiple constraints on
// the struct id's value type, such as implementing multiple interfaces. In
// this case, the tid would never equal or inherit from the template's TId,
// but we want instead to check for base type compatibility plus all interfaces.
(template.IsGenericTId &&
// TId is a derived class of the template's TId base type (i.e. object or ValueType)
tid.Is(template.TId.BaseType) &&
// All template provided TId interfaces must be implemented by the struct id's TId
template.TId.AllInterfaces.All(iface =>
tid.AllInterfaces.Any(tface => tface.Is(iface)))))
.Select(template => new IdTemplate(id, template));
});

context.RegisterSourceOutput(ids, GenerateCode);
}

void GenerateCode(SourceProductionContext context, IdTemplate source)
{
var hintName = $"{source.StructId.ToFileName()}-{source.Template.TSelf.Name}.cs";
var output = source.Template.NameExpr.Replace(source.Template.Text, source.StructId.Name);

if (source.StructId.ContainingNamespace.Equals(source.StructId.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default))
{
// No need to tweak target namespace.
context.AddSource(hintName, SourceText.From(output, Encoding.UTF8));
return;
}

// parse template into a C# compilation unit
var syntax = CSharpSyntaxTree.ParseText(output).GetCompilationUnitRoot();

// if we got a ns, move all members after a file-scoped namespace declaration
var members = syntax.Members;
var fsns = FileScopedNamespaceDeclaration(ParseName(source.StructId.ContainingNamespace.ToDisplayString())
.WithLeadingTrivia(Whitespace(" ")))
.WithLeadingTrivia(LineFeed)
.WithTrailingTrivia(LineFeed, LineFeed)
.WithMembers(members);

syntax = syntax.WithMembers(SingletonList<MemberDeclarationSyntax>(fsns));

output = syntax.ToFullString();
context.AddSource(hintName, SourceText.From(output, Encoding.UTF8));
}
}
9 changes: 8 additions & 1 deletion src/StructId.FunctionalTests/Functional.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace StructId.Functional;

public readonly partial record struct ProductId : IStructId<Guid>;
public readonly partial record struct ProductId(Guid Value) : IStructId<Guid>;
public readonly partial record struct UserId : IStructId<long>;
public readonly partial record struct WalletId : IStructId;

Expand Down Expand Up @@ -115,6 +115,13 @@ public void Dapper()
Assert.Equal(product, product2);
}

[Fact]
public void CustomTemplate()
{
var id = ProductId.New();
Assert.IsAssignableFrom<IId>(id);
}

public class Context : DbContext
{
public Context(DbContextOptions<Context> options) : base(options) { }
Expand Down
16 changes: 16 additions & 0 deletions src/StructId.FunctionalTests/IId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace StructId;

/// <summary>
/// Showcases a custom interface that we want implemented by all
/// guid-based struct ids.
/// </summary>
public interface IId
{
public Guid Id { get; }
}
12 changes: 12 additions & 0 deletions src/StructId.FunctionalTests/IIdTemplate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using StructId;

[TStructId]
file partial record struct IIdTemplate(Guid Value) : IId
{
public Guid Id => Value;
}
2 changes: 2 additions & 0 deletions src/StructId.Tests/TestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static TTest WithCodeFixStructId<TTest>(this TTest test) where TTest : Co
test.FixedState.Sources.Add(("IStructIdT.cs", ThisAssembly.Resources.StructId.IStructIdT.Text));
test.FixedState.Sources.Add(("INewable.cs", ThisAssembly.Resources.StructId.INewable.Text));
test.FixedState.Sources.Add(("INewableT.cs", ThisAssembly.Resources.StructId.INewableT.Text));
test.FixedState.Sources.Add(("TStructIdAttribute.cs", ThisAssembly.Resources.StructId.TStructIdAttribute.Text));

// Fixes error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported
test.FixedState.Sources.Add(
Expand Down Expand Up @@ -50,6 +51,7 @@ public static TTest WithAnalyzerStructId<TTest>(this TTest test) where TTest : A
test.TestState.Sources.Add(("IStructIdT.cs", ThisAssembly.Resources.StructId.IStructIdT.Text));
test.TestState.Sources.Add(("INewable.cs", ThisAssembly.Resources.StructId.INewable.Text));
test.TestState.Sources.Add(("INewableT.cs", ThisAssembly.Resources.StructId.INewableT.Text));
test.TestState.Sources.Add(("TStructIdAttribute.cs", ThisAssembly.Resources.StructId.TStructIdAttribute.Text));

// Fixes error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported
test.TestState.Sources.Add(
Expand Down
22 changes: 22 additions & 0 deletions src/StructId/TStructIdAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;

namespace StructId;

/// <summary>
/// Attribute for marking a template type for a struct id based on
/// a generic type parameter, which would implement <see cref="IStructId{TId}"/>.
/// </summary>
/// <typeparam name="TId">Template for the TId to replace in the <see cref="IStructId{TId}"/.></typeparam>
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class)]
public class TStructIdAttribute<TId> : Attribute
{
}

/// <summary>
/// Attribute for marking a template type for a struct id based on a string value,
/// which would implement <see cref="IStructId"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class)]
public class TStructIdAttribute : Attribute
{
}

0 comments on commit 24f3a62

Please sign in to comment.