Skip to content

Commit 76aa30c

Browse files
committed
Add Source Generator which adds virtual interface methods to C#
sorta like C# 8 default interface methods, but the base implementations are filled in at compile-time
1 parent 7efafc1 commit 76aa30c

10 files changed

+358
-0
lines changed

Common.ruleset

+3
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
<!-- Don't call typeof(T).ToString(), use nameof operator or typeof(T).FullName -->
2929
<Rule Id="BHI1103" Action="Error" />
3030

31+
<!-- Only apply [VirtualMethod] to (abstract) methods and property/event accessors -->
32+
<Rule Id="BHI2000" Action="Error" />
33+
3134
<!-- Call to FirstOrDefault when elements are of a value type; FirstOrNull may have been intended -->
3235
<Rule Id="BHI3100" Action="Error" />
3336

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace BizHawk.Analyzers;
2+
3+
public static class Extensions
4+
{
5+
public static string RemovePrefix(this string str, string prefix)
6+
=> str.StartsWith(prefix) ? str.Substring(prefix.Length, str.Length - prefix.Length) : str;
7+
8+
public static void SwapReferences<T>(ref T a, ref T b)
9+
{
10+
ref T c = ref a; // using var results in CS8619 warning?
11+
a = ref b;
12+
b = ref c;
13+
}
14+
}

ExternalProjects/AnalyzersCommon/RoslynUtils.cs

+64
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
namespace BizHawk.Analyzers;
22

3+
using System.Collections.Generic;
4+
using System.Linq;
5+
36
using Microsoft.CodeAnalysis;
47
using Microsoft.CodeAnalysis.CSharp;
58
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -13,6 +16,67 @@ public static class RoslynUtils
1316
return parent;
1417
}
1518

19+
public static string FullNamespace(this ISymbol? sym)
20+
{
21+
if (sym is null) return string.Empty;
22+
var s = sym.Name;
23+
var ns = sym.ContainingNamespace;
24+
while (ns is { IsGlobalNamespace: false })
25+
{
26+
s = $"{ns.Name}.{s}";
27+
ns = ns.ContainingNamespace;
28+
}
29+
return s;
30+
}
31+
32+
/// <param name="sym">required to differentiate <c>record class</c> and <c>record struct</c> (later Roslyn versions will make this redundant)</param>
33+
/// <returns>
34+
/// one of:
35+
/// <list type="bullet">
36+
/// <item><description><c>{ "abstract", "class" }</c></description></item>
37+
/// <item><description><c>{ "abstract", "partial", "class" }</c></description></item>
38+
/// <item><description><c>{ "class" }</c></description></item>
39+
/// <item><description><c>{ "enum" }</c></description></item>
40+
/// <item><description><c>{ "interface" }</c></description></item>
41+
/// <item><description><c>{ "partial", "class" }</c></description></item>
42+
/// <item><description><c>{ "partial", "interface" }</c></description></item>
43+
/// <item><description><c>{ "partial", "record", "class" }</c></description></item>
44+
/// <item><description><c>{ "partial", "record", "struct" }</c></description></item>
45+
/// <item><description><c>{ "partial", "struct" }</c></description></item>
46+
/// <item><description><c>{ "record", "class" }</c></description></item>
47+
/// <item><description><c>{ "record", "struct" }</c></description></item>
48+
/// <item><description><c>{ "sealed", "class" }</c></description></item>
49+
/// <item><description><c>{ "sealed", "partial", "class" }</c></description></item>
50+
/// <item><description><c>{ "static", "class" }</c></description></item>
51+
/// <item><description><c>{ "static", "partial", "class" }</c></description></item>
52+
/// <item><description><c>{ "struct" }</c></description></item>
53+
/// </list>
54+
/// </returns>
55+
/// <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>
56+
public static IReadOnlyList<string> GetTypeKeywords(this BaseTypeDeclarationSyntax btds, INamedTypeSymbol sym)
57+
{
58+
// maybe it would make more sense to have a [Flags] enum (Abstract | Concrete | Delegate | Enum | Partial | Record | Sealed | ValueType) okay I've overengineered this
59+
// what about using ONLY cSym? I think that's more correct anyway as it combines partial interfaces
60+
if (btds is EnumDeclarationSyntax) return new[] { /*eds.EnumKeyword.Text*/"enum" };
61+
var tds = (TypeDeclarationSyntax) btds;
62+
List<string> keywords = new() { tds.Keyword.Text };
63+
#if true
64+
if (tds is RecordDeclarationSyntax) keywords.Add(sym.IsValueType ? "struct" : "class");
65+
#else // requires newer Roslyn
66+
if (tds is RecordDeclarationSyntax rds)
67+
{
68+
var s = rds.ClassOrStructKeyword.Text;
69+
keywords.Add(s is "" ? "class" : s);
70+
}
71+
#endif
72+
var mods = tds.Modifiers.Select(static st => st.Text).ToList();
73+
if (mods.Contains("partial")) keywords.Insert(0, "partial");
74+
if (mods.Contains("abstract")) keywords.Insert(0, "abstract");
75+
else if (mods.Contains("sealed")) keywords.Insert(0, "sealed");
76+
else if (mods.Contains("static")) keywords.Insert(0, "static");
77+
return keywords;
78+
}
79+
1680
private static ITypeSymbol? GetThrownExceptionType(this SemanticModel model, ExpressionSyntax exprSyn)
1781
=> exprSyn is ObjectCreationExpressionSyntax
1882
? model.GetTypeInfo(exprSyn).Type
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>netstandard2.0</TargetFramework>
4+
</PropertyGroup>
5+
<Import Project="../AnalyzersCommon.props" />
6+
<ItemGroup>
7+
<EmbeddedResource Include="VirtualMethodAttribute.cs" />
8+
</ItemGroup>
9+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
namespace BizHawk.SrcGen.VIM;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text;
7+
8+
using BizHawk.Analyzers;
9+
using BizHawk.Common;
10+
11+
using Microsoft.CodeAnalysis;
12+
using Microsoft.CodeAnalysis.CSharp;
13+
using Microsoft.CodeAnalysis.CSharp.Syntax;
14+
using Microsoft.CodeAnalysis.Text;
15+
16+
using ImplNotesList = System.Collections.Generic.Dictionary<string, System.Collections.Generic.List<BizHawk.SrcGen.VIM.VIMGenerator.ImplNotes>>;
17+
18+
[Generator]
19+
public sealed class VIMGenerator : ISourceGenerator
20+
{
21+
internal readonly struct ImplNotes
22+
{
23+
public readonly string AccessorKeyword;
24+
25+
public readonly string BaseImplNamePrefix;
26+
27+
public readonly string InvokeCall;
28+
29+
public readonly bool IsSetOrRemove
30+
=> MethodSym.MethodKind is MethodKind.PropertySet or MethodKind.EventRemove;
31+
32+
public readonly string MemberFullNameArgs;
33+
34+
public readonly IMethodSymbol MethodSym;
35+
36+
public readonly string ReturnType;
37+
38+
public ImplNotes(IMethodSymbol methodSym, string memberFullNameArgs, string baseImplNamePrefix)
39+
{
40+
BaseImplNamePrefix = baseImplNamePrefix;
41+
MemberFullNameArgs = memberFullNameArgs;
42+
MethodSym = methodSym;
43+
switch (methodSym.MethodKind)
44+
{
45+
case MethodKind.Ordinary:
46+
AccessorKeyword = string.Empty;
47+
InvokeCall = $"(this{string.Concat(methodSym.Parameters.Select(static pSym => $", {pSym.Name}"))})";
48+
MemberFullNameArgs += $"({string.Join(", ", methodSym.Parameters.Select(static pSym => $"{pSym.Type.ToDisplayString()} {pSym.Name}"))})";
49+
ReturnType = methodSym.ReturnType.ToDisplayString();
50+
break;
51+
case MethodKind.PropertyGet:
52+
AccessorKeyword = "get";
53+
InvokeCall = "(this)";
54+
ReturnType = methodSym.ReturnType.ToDisplayString();
55+
break;
56+
case MethodKind.PropertySet:
57+
AccessorKeyword = "set";
58+
InvokeCall = "(this, value)";
59+
ReturnType = ((IPropertySymbol) methodSym.AssociatedSymbol!).Type.ToDisplayString(); // only used for set-only props
60+
break;
61+
case MethodKind.EventAdd:
62+
AccessorKeyword = "add";
63+
InvokeCall = "(this, value)";
64+
ReturnType = $"event {((IEventSymbol) methodSym.AssociatedSymbol!).Type.ToDisplayString()}";
65+
break;
66+
case MethodKind.EventRemove:
67+
AccessorKeyword = "remove";
68+
InvokeCall = "(this, value)";
69+
ReturnType = string.Empty; // unused
70+
break;
71+
default:
72+
throw new InvalidOperationException();
73+
}
74+
if (!string.IsNullOrEmpty(AccessorKeyword)) BaseImplNamePrefix += $"_{AccessorKeyword}";
75+
}
76+
}
77+
78+
private sealed class VIMGenSyntaxReceiver : ISyntaxReceiver
79+
{
80+
public readonly List<TypeDeclarationSyntax> Candidates = new();
81+
82+
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
83+
{
84+
if (syntaxNode is TypeDeclarationSyntax syn) Candidates.Add(syn);
85+
}
86+
}
87+
88+
private static readonly DiagnosticDescriptor DiagCantMakeVirtual = new(
89+
id: "BHI2000",
90+
title: "Only apply [VirtualMethod] to (abstract) methods and property/event accessors",
91+
messageFormat: "Can't apply [VirtualMethod] to this kind of member, only methods and property/event accessors",
92+
category: "Usage",
93+
defaultSeverity: DiagnosticSeverity.Warning,
94+
isEnabledByDefault: true);
95+
96+
#if false
97+
private static readonly DiagnosticDescriptor DiagDebug = new(
98+
id: "BHI2099",
99+
title: "debug",
100+
messageFormat: "{0}",
101+
category: "Usage",
102+
defaultSeverity: DiagnosticSeverity.Warning,
103+
isEnabledByDefault: true);
104+
#endif
105+
106+
//TODO warning for attr used on member of class/struct/record?
107+
108+
//TODO warning for only one of get/set/add/remove pair has attr?
109+
110+
//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
111+
112+
public void Initialize(GeneratorInitializationContext context)
113+
=> context.RegisterForSyntaxNotifications(static () => new VIMGenSyntaxReceiver());
114+
115+
public void Execute(GeneratorExecutionContext context)
116+
{
117+
if (context.SyntaxReceiver is not VIMGenSyntaxReceiver receiver) return;
118+
119+
// boilerplate to get attr working
120+
var compilation = context.Compilation;
121+
var vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute));
122+
if (vimAttrSymbol is null)
123+
{
124+
var attributesSource = SourceText.From(typeof(VIMGenerator).Assembly.GetManifestResourceStream("BizHawk.SrcGen.VIM.VirtualMethodAttribute.cs")!, Encoding.UTF8, canBeEmbedded: true);
125+
context.AddSource("VirtualMethodAttribute.cs", attributesSource);
126+
compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(attributesSource, (CSharpParseOptions) ((CSharpCompilation) context.Compilation).SyntaxTrees[0].Options));
127+
vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute))!;
128+
}
129+
130+
Dictionary<string, ImplNotesList> vimDict = new();
131+
ImplNotesList Lookup(INamedTypeSymbol intfSym)
132+
{
133+
var fqn = intfSym.FullNamespace();
134+
if (vimDict.TryGetValue(fqn, out var implNotes)) return implNotes;
135+
// else cache miss
136+
ImplNotesList implNotes1 = new();
137+
static (string? ImplsClassFullName, string? BaseImplMethodName) ParseVIMAttr(AttributeData vimAttr)
138+
{
139+
string? baseImplMethodName = null;
140+
string? implsClassFullName = null;
141+
foreach (var kvp in vimAttr.NamedArguments) switch (kvp.Key)
142+
{
143+
case nameof(VirtualMethodAttribute.BaseImplMethodName):
144+
baseImplMethodName = kvp.Value.Value?.ToString();
145+
break;
146+
case nameof(VirtualMethodAttribute.ImplsClassFullName):
147+
implsClassFullName = kvp.Value.Value?.ToString();
148+
break;
149+
}
150+
return (implsClassFullName, baseImplMethodName);
151+
}
152+
void AddMethodNotes(IMethodSymbol methodSym, (string? ImplsClassFullName, string? BaseImplMethodName) attrProps)
153+
{
154+
var memberName = methodSym.MethodKind is MethodKind.Ordinary ? methodSym.Name : methodSym.AssociatedSymbol!.Name;
155+
var memberFullNameArgs = $"{intfSym.FullNamespace()}.{memberName}";
156+
var baseImplNamePrefix = $"{(attrProps.ImplsClassFullName ?? $"{intfSym.FullNamespace()}.MethodDefaultImpls")}.{attrProps.BaseImplMethodName ?? memberName}";
157+
if (!implNotes1.TryGetValue(memberFullNameArgs, out var parts)) parts = implNotes1[memberFullNameArgs] = new();
158+
parts.Add(new(methodSym, memberFullNameArgs: memberFullNameArgs, baseImplNamePrefix: baseImplNamePrefix));
159+
}
160+
foreach (var memberSym in intfSym.GetMembers())
161+
{
162+
var vimAttr = memberSym.GetAttributes().FirstOrDefault(ad => vimAttrSymbol.Matches(ad.AttributeClass));
163+
switch (memberSym)
164+
{
165+
case IMethodSymbol methodSym: // methods and prop accessors (accessors in interface events are an error without DIM)
166+
if (vimAttr is null) continue;
167+
if (methodSym.MethodKind is not (MethodKind.Ordinary or MethodKind.PropertyGet or MethodKind.PropertySet))
168+
{
169+
// no idea what would actually trigger this
170+
context.ReportDiagnostic(Diagnostic.Create(DiagCantMakeVirtual, vimAttr.ApplicationSyntaxReference!.GetSyntax().GetLocation()));
171+
continue;
172+
}
173+
AddMethodNotes(methodSym, ParseVIMAttr(vimAttr));
174+
continue;
175+
case IPropertySymbol propSym: // props
176+
if (vimAttr is null) continue;
177+
var parsed = ParseVIMAttr(vimAttr);
178+
if (propSym.GetMethod is {} getter) AddMethodNotes(getter, parsed);
179+
if (propSym.SetMethod is {} setter) AddMethodNotes(setter, parsed);
180+
continue;
181+
case IEventSymbol eventSym: // events
182+
if (vimAttr is null) continue;
183+
var parsed1 = ParseVIMAttr(vimAttr);
184+
AddMethodNotes(eventSym.AddMethod!, parsed1);
185+
AddMethodNotes(eventSym.RemoveMethod!, parsed1);
186+
continue;
187+
}
188+
}
189+
190+
return vimDict[fqn] = implNotes1;
191+
}
192+
193+
List<INamedTypeSymbol> seen = new();
194+
foreach (var tds in receiver.Candidates)
195+
{
196+
var cSym = compilation.GetSemanticModel(tds.SyntaxTree).GetDeclaredSymbol(tds)!;
197+
if (seen.Contains(cSym)) continue; // dedup partial classes
198+
seen.Add(cSym);
199+
var typeKeywords = tds.GetTypeKeywords(cSym);
200+
if (typeKeywords.Contains("enum") || typeKeywords.Contains("interface") || typeKeywords.Contains("static")) continue;
201+
202+
var nSpace = cSym.ContainingNamespace.ToDisplayString();
203+
var nSpaceDot = $"{nSpace}.";
204+
List<string> innerText = new();
205+
var intfsToImplement = cSym.BaseType is not null
206+
? cSym.AllInterfaces.Except(cSym.BaseType.AllInterfaces) // superclass (or its superclass, etc.) already has the delegated base implementations of these interfaces' virtual methods
207+
: cSym.AllInterfaces;
208+
//TODO let an interface override a superinterface's virtual method -- may need to order intfsToImplement somehow
209+
foreach (var methodParts in intfsToImplement.SelectMany(intfSym => Lookup(intfSym).Values))
210+
{
211+
var methodSym = methodParts[0].MethodSym;
212+
if (cSym.FindImplementationForInterfaceMember(methodSym) is not null) continue; // overridden
213+
var memberImplText = $"{methodParts[0].ReturnType} {methodParts[0].MemberFullNameArgs.RemovePrefix(nSpaceDot)}";
214+
if (methodSym.MethodKind is MethodKind.Ordinary)
215+
{
216+
memberImplText += $"\n\t\t\t=> {methodParts[0].BaseImplNamePrefix.RemovePrefix(nSpaceDot)}{methodParts[0].InvokeCall};";
217+
}
218+
else
219+
{
220+
if (methodParts[0].IsSetOrRemove) methodParts.Reverse();
221+
memberImplText += $"\n\t\t{{{string.Concat(methodParts.Select(methodNotes => $"\n\t\t\t{methodNotes.AccessorKeyword} => {methodNotes.BaseImplNamePrefix.RemovePrefix(nSpaceDot)}{methodNotes.InvokeCall};"))}\n\t\t}}";
222+
}
223+
innerText.Add(memberImplText);
224+
}
225+
if (innerText.Count is not 0) context.AddSource(
226+
source: $@"#nullable enable
227+
228+
namespace {nSpace}
229+
{{
230+
public {string.Join(" ", typeKeywords)} {cSym.Name}
231+
{{
232+
{string.Join("\n\n\t\t", innerText)}
233+
}}
234+
}}
235+
",
236+
hintName: $"{cSym.Name}.VIMDelegation.cs");
237+
}
238+
}
239+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#nullable enable // for when this file is embedded
2+
3+
using System;
4+
5+
namespace BizHawk.Common
6+
{
7+
/// <summary>
8+
/// Allows <see langword="abstract"/> methods in interfaces to be treated like <see langword="virtual"/> methods, similar to how they behave in classes.
9+
/// 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).
10+
/// </summary>
11+
/// <remarks>
12+
/// 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/>
13+
/// 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.
14+
/// </remarks>
15+
/// <seealso cref="BaseImplMethodName"/>
16+
/// <seealso cref="ImplsClassFullName"/>
17+
[AttributeUsage(AttributeTargets.Event | AttributeTargets.Property | AttributeTargets.Method, Inherited = false)]
18+
public sealed class VirtualMethodAttribute : Attribute
19+
{
20+
/// <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>
21+
public string? BaseImplMethodName { get; set; } = null;
22+
23+
/// <remarks>if unset, uses <c>$"{interfaceFullName}.MethodDefaultImpls"</c></remarks>
24+
public string? ImplsClassFullName { get; set; } = null;
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../.build_debug.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../.build_release.sh

References/BizHawk.SrcGen.VIM.dll

20.5 KB
Binary file not shown.

src/MainSlnCommon.props

+1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212
</PropertyGroup>
1313
<ItemGroup>
1414
<Analyzer Include="$(ProjectDir)../../References/BizHawk.SrcGen.ReflectionCache.dll" />
15+
<Analyzer Include="$(ProjectDir)../../References/BizHawk.SrcGen.VIM.dll" />
1516
</ItemGroup>
1617
</Project>

0 commit comments

Comments
 (0)