Skip to content

Commit

Permalink
Add Source Generator which adds virtual interface methods to C#
Browse files Browse the repository at this point in the history
sorta like C# 8 default interface methods, but the base implementations are
filled in at compile-time
  • Loading branch information
YoshiRulz committed Sep 30, 2022
1 parent 7efafc1 commit 76aa30c
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 0 deletions.
3 changes: 3 additions & 0 deletions Common.ruleset
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<!-- Don't call typeof(T).ToString(), use nameof operator or typeof(T).FullName -->
<Rule Id="BHI1103" Action="Error" />

<!-- Only apply [VirtualMethod] to (abstract) methods and property/event accessors -->
<Rule Id="BHI2000" Action="Error" />

<!-- Call to FirstOrDefault when elements are of a value type; FirstOrNull may have been intended -->
<Rule Id="BHI3100" Action="Error" />

Expand Down
14 changes: 14 additions & 0 deletions ExternalProjects/AnalyzersCommon/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace BizHawk.Analyzers;

public static class Extensions
{
public static string RemovePrefix(this string str, string prefix)
=> str.StartsWith(prefix) ? str.Substring(prefix.Length, str.Length - prefix.Length) : str;

public static void SwapReferences<T>(ref T a, ref T b)
{
ref T c = ref a; // using var results in CS8619 warning?
a = ref b;
b = ref c;
}
}
64 changes: 64 additions & 0 deletions ExternalProjects/AnalyzersCommon/RoslynUtils.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
namespace BizHawk.Analyzers;

using System.Collections.Generic;
using System.Linq;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
Expand All @@ -13,6 +16,67 @@ public static class RoslynUtils
return parent;
}

public static string FullNamespace(this ISymbol? sym)
{
if (sym is null) return string.Empty;
var s = sym.Name;
var ns = sym.ContainingNamespace;
while (ns is { IsGlobalNamespace: false })
{
s = $"{ns.Name}.{s}";
ns = ns.ContainingNamespace;
}
return s;
}

/// <param name="sym">required to differentiate <c>record class</c> and <c>record struct</c> (later Roslyn versions will make this redundant)</param>
/// <returns>
/// one of:
/// <list type="bullet">
/// <item><description><c>{ "abstract", "class" }</c></description></item>
/// <item><description><c>{ "abstract", "partial", "class" }</c></description></item>
/// <item><description><c>{ "class" }</c></description></item>
/// <item><description><c>{ "enum" }</c></description></item>
/// <item><description><c>{ "interface" }</c></description></item>
/// <item><description><c>{ "partial", "class" }</c></description></item>
/// <item><description><c>{ "partial", "interface" }</c></description></item>
/// <item><description><c>{ "partial", "record", "class" }</c></description></item>
/// <item><description><c>{ "partial", "record", "struct" }</c></description></item>
/// <item><description><c>{ "partial", "struct" }</c></description></item>
/// <item><description><c>{ "record", "class" }</c></description></item>
/// <item><description><c>{ "record", "struct" }</c></description></item>
/// <item><description><c>{ "sealed", "class" }</c></description></item>
/// <item><description><c>{ "sealed", "partial", "class" }</c></description></item>
/// <item><description><c>{ "static", "class" }</c></description></item>
/// <item><description><c>{ "static", "partial", "class" }</c></description></item>
/// <item><description><c>{ "struct" }</c></description></item>
/// </list>
/// </returns>
/// <remarks>this list is correct and complete as of C# 10, despite what the official documentation of these keywords might say (<c>static partial class</c> nowhere in sight)</remarks>
public static IReadOnlyList<string> GetTypeKeywords(this BaseTypeDeclarationSyntax btds, INamedTypeSymbol sym)
{
// maybe it would make more sense to have a [Flags] enum (Abstract | Concrete | Delegate | Enum | Partial | Record | Sealed | ValueType) okay I've overengineered this
// what about using ONLY cSym? I think that's more correct anyway as it combines partial interfaces
if (btds is EnumDeclarationSyntax) return new[] { /*eds.EnumKeyword.Text*/"enum" };
var tds = (TypeDeclarationSyntax) btds;
List<string> keywords = new() { tds.Keyword.Text };
#if true
if (tds is RecordDeclarationSyntax) keywords.Add(sym.IsValueType ? "struct" : "class");
#else // requires newer Roslyn
if (tds is RecordDeclarationSyntax rds)
{
var s = rds.ClassOrStructKeyword.Text;
keywords.Add(s is "" ? "class" : s);
}
#endif
var mods = tds.Modifiers.Select(static st => st.Text).ToList();
if (mods.Contains("partial")) keywords.Insert(0, "partial");
if (mods.Contains("abstract")) keywords.Insert(0, "abstract");
else if (mods.Contains("sealed")) keywords.Insert(0, "sealed");
else if (mods.Contains("static")) keywords.Insert(0, "static");
return keywords;
}

private static ITypeSymbol? GetThrownExceptionType(this SemanticModel model, ExpressionSyntax exprSyn)
=> exprSyn is ObjectCreationExpressionSyntax
? model.GetTypeInfo(exprSyn).Type
Expand Down
9 changes: 9 additions & 0 deletions ExternalProjects/BizHawk.SrcGen.VIM/BizHawk.SrcGen.VIM.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<Import Project="../AnalyzersCommon.props" />
<ItemGroup>
<EmbeddedResource Include="VirtualMethodAttribute.cs" />
</ItemGroup>
</Project>
239 changes: 239 additions & 0 deletions ExternalProjects/BizHawk.SrcGen.VIM/VIMGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
namespace BizHawk.SrcGen.VIM;

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using BizHawk.Analyzers;
using BizHawk.Common;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

using ImplNotesList = System.Collections.Generic.Dictionary<string, System.Collections.Generic.List<BizHawk.SrcGen.VIM.VIMGenerator.ImplNotes>>;

[Generator]
public sealed class VIMGenerator : ISourceGenerator
{
internal readonly struct ImplNotes
{
public readonly string AccessorKeyword;

public readonly string BaseImplNamePrefix;

public readonly string InvokeCall;

public readonly bool IsSetOrRemove
=> MethodSym.MethodKind is MethodKind.PropertySet or MethodKind.EventRemove;

public readonly string MemberFullNameArgs;

public readonly IMethodSymbol MethodSym;

public readonly string ReturnType;

public ImplNotes(IMethodSymbol methodSym, string memberFullNameArgs, string baseImplNamePrefix)
{
BaseImplNamePrefix = baseImplNamePrefix;
MemberFullNameArgs = memberFullNameArgs;
MethodSym = methodSym;
switch (methodSym.MethodKind)
{
case MethodKind.Ordinary:
AccessorKeyword = string.Empty;
InvokeCall = $"(this{string.Concat(methodSym.Parameters.Select(static pSym => $", {pSym.Name}"))})";
MemberFullNameArgs += $"({string.Join(", ", methodSym.Parameters.Select(static pSym => $"{pSym.Type.ToDisplayString()} {pSym.Name}"))})";
ReturnType = methodSym.ReturnType.ToDisplayString();
break;
case MethodKind.PropertyGet:
AccessorKeyword = "get";
InvokeCall = "(this)";
ReturnType = methodSym.ReturnType.ToDisplayString();
break;
case MethodKind.PropertySet:
AccessorKeyword = "set";
InvokeCall = "(this, value)";
ReturnType = ((IPropertySymbol) methodSym.AssociatedSymbol!).Type.ToDisplayString(); // only used for set-only props
break;
case MethodKind.EventAdd:
AccessorKeyword = "add";
InvokeCall = "(this, value)";
ReturnType = $"event {((IEventSymbol) methodSym.AssociatedSymbol!).Type.ToDisplayString()}";
break;
case MethodKind.EventRemove:
AccessorKeyword = "remove";
InvokeCall = "(this, value)";
ReturnType = string.Empty; // unused
break;
default:
throw new InvalidOperationException();
}
if (!string.IsNullOrEmpty(AccessorKeyword)) BaseImplNamePrefix += $"_{AccessorKeyword}";
}
}

private sealed class VIMGenSyntaxReceiver : ISyntaxReceiver
{
public readonly List<TypeDeclarationSyntax> Candidates = new();

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is TypeDeclarationSyntax syn) Candidates.Add(syn);
}
}

private static readonly DiagnosticDescriptor DiagCantMakeVirtual = new(
id: "BHI2000",
title: "Only apply [VirtualMethod] to (abstract) methods and property/event accessors",
messageFormat: "Can't apply [VirtualMethod] to this kind of member, only methods and property/event accessors",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

#if false
private static readonly DiagnosticDescriptor DiagDebug = new(
id: "BHI2099",
title: "debug",
messageFormat: "{0}",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
#endif

//TODO warning for attr used on member of class/struct/record?

//TODO warning for only one of get/set/add/remove pair has attr?

//TODO warning for unused base implementation (i.e. impl/override exists in every direct implementor)? ofc the attribute can be pointing to any static method, so the base implementation itself shouldn't be marked unused

public void Initialize(GeneratorInitializationContext context)
=> context.RegisterForSyntaxNotifications(static () => new VIMGenSyntaxReceiver());

public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not VIMGenSyntaxReceiver receiver) return;

// boilerplate to get attr working
var compilation = context.Compilation;
var vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute));
if (vimAttrSymbol is null)
{
var attributesSource = SourceText.From(typeof(VIMGenerator).Assembly.GetManifestResourceStream("BizHawk.SrcGen.VIM.VirtualMethodAttribute.cs")!, Encoding.UTF8, canBeEmbedded: true);
context.AddSource("VirtualMethodAttribute.cs", attributesSource);
compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(attributesSource, (CSharpParseOptions) ((CSharpCompilation) context.Compilation).SyntaxTrees[0].Options));
vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute))!;
}

Dictionary<string, ImplNotesList> vimDict = new();
ImplNotesList Lookup(INamedTypeSymbol intfSym)
{
var fqn = intfSym.FullNamespace();
if (vimDict.TryGetValue(fqn, out var implNotes)) return implNotes;
// else cache miss
ImplNotesList implNotes1 = new();
static (string? ImplsClassFullName, string? BaseImplMethodName) ParseVIMAttr(AttributeData vimAttr)
{
string? baseImplMethodName = null;
string? implsClassFullName = null;
foreach (var kvp in vimAttr.NamedArguments) switch (kvp.Key)
{
case nameof(VirtualMethodAttribute.BaseImplMethodName):
baseImplMethodName = kvp.Value.Value?.ToString();
break;
case nameof(VirtualMethodAttribute.ImplsClassFullName):
implsClassFullName = kvp.Value.Value?.ToString();
break;
}
return (implsClassFullName, baseImplMethodName);
}
void AddMethodNotes(IMethodSymbol methodSym, (string? ImplsClassFullName, string? BaseImplMethodName) attrProps)
{
var memberName = methodSym.MethodKind is MethodKind.Ordinary ? methodSym.Name : methodSym.AssociatedSymbol!.Name;
var memberFullNameArgs = $"{intfSym.FullNamespace()}.{memberName}";
var baseImplNamePrefix = $"{(attrProps.ImplsClassFullName ?? $"{intfSym.FullNamespace()}.MethodDefaultImpls")}.{attrProps.BaseImplMethodName ?? memberName}";
if (!implNotes1.TryGetValue(memberFullNameArgs, out var parts)) parts = implNotes1[memberFullNameArgs] = new();
parts.Add(new(methodSym, memberFullNameArgs: memberFullNameArgs, baseImplNamePrefix: baseImplNamePrefix));
}
foreach (var memberSym in intfSym.GetMembers())
{
var vimAttr = memberSym.GetAttributes().FirstOrDefault(ad => vimAttrSymbol.Matches(ad.AttributeClass));
switch (memberSym)
{
case IMethodSymbol methodSym: // methods and prop accessors (accessors in interface events are an error without DIM)
if (vimAttr is null) continue;
if (methodSym.MethodKind is not (MethodKind.Ordinary or MethodKind.PropertyGet or MethodKind.PropertySet))
{
// no idea what would actually trigger this
context.ReportDiagnostic(Diagnostic.Create(DiagCantMakeVirtual, vimAttr.ApplicationSyntaxReference!.GetSyntax().GetLocation()));
continue;
}
AddMethodNotes(methodSym, ParseVIMAttr(vimAttr));
continue;
case IPropertySymbol propSym: // props
if (vimAttr is null) continue;
var parsed = ParseVIMAttr(vimAttr);
if (propSym.GetMethod is {} getter) AddMethodNotes(getter, parsed);
if (propSym.SetMethod is {} setter) AddMethodNotes(setter, parsed);
continue;
case IEventSymbol eventSym: // events
if (vimAttr is null) continue;
var parsed1 = ParseVIMAttr(vimAttr);
AddMethodNotes(eventSym.AddMethod!, parsed1);
AddMethodNotes(eventSym.RemoveMethod!, parsed1);
continue;
}
}

return vimDict[fqn] = implNotes1;
}

List<INamedTypeSymbol> seen = new();
foreach (var tds in receiver.Candidates)
{
var cSym = compilation.GetSemanticModel(tds.SyntaxTree).GetDeclaredSymbol(tds)!;
if (seen.Contains(cSym)) continue; // dedup partial classes
seen.Add(cSym);
var typeKeywords = tds.GetTypeKeywords(cSym);
if (typeKeywords.Contains("enum") || typeKeywords.Contains("interface") || typeKeywords.Contains("static")) continue;

var nSpace = cSym.ContainingNamespace.ToDisplayString();
var nSpaceDot = $"{nSpace}.";
List<string> innerText = new();
var intfsToImplement = cSym.BaseType is not null
? cSym.AllInterfaces.Except(cSym.BaseType.AllInterfaces) // superclass (or its superclass, etc.) already has the delegated base implementations of these interfaces' virtual methods
: cSym.AllInterfaces;
//TODO let an interface override a superinterface's virtual method -- may need to order intfsToImplement somehow
foreach (var methodParts in intfsToImplement.SelectMany(intfSym => Lookup(intfSym).Values))
{
var methodSym = methodParts[0].MethodSym;
if (cSym.FindImplementationForInterfaceMember(methodSym) is not null) continue; // overridden
var memberImplText = $"{methodParts[0].ReturnType} {methodParts[0].MemberFullNameArgs.RemovePrefix(nSpaceDot)}";
if (methodSym.MethodKind is MethodKind.Ordinary)
{
memberImplText += $"\n\t\t\t=> {methodParts[0].BaseImplNamePrefix.RemovePrefix(nSpaceDot)}{methodParts[0].InvokeCall};";
}
else
{
if (methodParts[0].IsSetOrRemove) methodParts.Reverse();
memberImplText += $"\n\t\t{{{string.Concat(methodParts.Select(methodNotes => $"\n\t\t\t{methodNotes.AccessorKeyword} => {methodNotes.BaseImplNamePrefix.RemovePrefix(nSpaceDot)}{methodNotes.InvokeCall};"))}\n\t\t}}";
}
innerText.Add(memberImplText);
}
if (innerText.Count is not 0) context.AddSource(
source: $@"#nullable enable
namespace {nSpace}
{{
public {string.Join(" ", typeKeywords)} {cSym.Name}
{{
{string.Join("\n\n\t\t", innerText)}
}}
}}
",
hintName: $"{cSym.Name}.VIMDelegation.cs");
}
}
}
26 changes: 26 additions & 0 deletions ExternalProjects/BizHawk.SrcGen.VIM/VirtualMethodAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#nullable enable // for when this file is embedded

using System;

namespace BizHawk.Common
{
/// <summary>
/// Allows <see langword="abstract"/> methods in interfaces to be treated like <see langword="virtual"/> methods, similar to how they behave in classes.
/// And the same for <see langword="abstract"/> property accessors and <see langword="abstract"/> events (accessors in interface events are an error without DIM, apply to event).
/// </summary>
/// <remarks>
/// The base implementation can't be written into the interface, so it needs to be in a separate (usually inner) static class. A Source Generator will then add the necessary delegating method implementations at compile-time.<br/>
/// These faux-<see langword="virtual"/> methods support the same <see langword="override"/>/<see langword="sealed"/> mechanisms that you'd expect of classes: just apply the keyword as usual.
/// </remarks>
/// <seealso cref="BaseImplMethodName"/>
/// <seealso cref="ImplsClassFullName"/>
[AttributeUsage(AttributeTargets.Event | AttributeTargets.Property | AttributeTargets.Method, Inherited = false)]
public sealed class VirtualMethodAttribute : Attribute
{
/// <remarks>if unset, uses annotated method's name (with <c>_get</c>/<c>_set</c>/<c>_add</c>/<c>_remove</c> suffix for props/events)</remarks>
public string? BaseImplMethodName { get; set; } = null;

/// <remarks>if unset, uses <c>$"{interfaceFullName}.MethodDefaultImpls"</c></remarks>
public string? ImplsClassFullName { get; set; } = null;
}
}
1 change: 1 addition & 0 deletions ExternalProjects/BizHawk.SrcGen.VIM/build_debug.sh
1 change: 1 addition & 0 deletions ExternalProjects/BizHawk.SrcGen.VIM/build_release.sh
Binary file added References/BizHawk.SrcGen.VIM.dll
Binary file not shown.
1 change: 1 addition & 0 deletions src/MainSlnCommon.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
</PropertyGroup>
<ItemGroup>
<Analyzer Include="$(ProjectDir)../../References/BizHawk.SrcGen.ReflectionCache.dll" />
<Analyzer Include="$(ProjectDir)../../References/BizHawk.SrcGen.VIM.dll" />
</ItemGroup>
</Project>

0 comments on commit 76aa30c

Please sign in to comment.