Skip to content

Commit 6cf66ef

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 6113f3c commit 6cf66ef

File tree

6 files changed

+276
-0
lines changed

6 files changed

+276
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>netstandard2.0</TargetFramework>
4+
</PropertyGroup>
5+
<Import Project="../../Common.props" />
6+
<ItemGroup>
7+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2" PrivateAssets="all" />
8+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
9+
<EmbeddedResource Include="VirtualMethodAttribute.cs" />
10+
</ItemGroup>
11+
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
12+
<Copy SourceFiles="$(OutputPath)BizHawk.SrcGen.VIM.dll" DestinationFolder="$(ProjectDir)../../References/" />
13+
</Target>
14+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
namespace BizHawk.SrcGen.VIM;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
11+
internal static class RoslynUtils
12+
{
13+
public static SyntaxNode? EnclosingTypeDeclarationSyntax(this CSharpSyntaxNode node)
14+
{
15+
var parent = node.Parent;
16+
while (parent is not (null or TypeDeclarationSyntax)) parent = parent.Parent;
17+
return parent;
18+
}
19+
20+
/// <param name="sym">required to differentiate <c>record class</c> and <c>record struct</c> (later Roslyn versions will make this redundant)</param>
21+
/// <returns>
22+
/// one of:
23+
/// <list type="bullet">
24+
/// <item><description><c>{ "abstract", "class" }</c></description></item>
25+
/// <item><description><c>{ "abstract", "partial", "class" }</c></description></item>
26+
/// <item><description><c>{ "class" }</c></description></item>
27+
/// <item><description><c>{ "enum" }</c></description></item>
28+
/// <item><description><c>{ "interface" }</c></description></item>
29+
/// <item><description><c>{ "partial", "class" }</c></description></item>
30+
/// <item><description><c>{ "partial", "interface" }</c></description></item>
31+
/// <item><description><c>{ "partial", "record", "class" }</c></description></item>
32+
/// <item><description><c>{ "partial", "record", "struct" }</c></description></item>
33+
/// <item><description><c>{ "partial", "struct" }</c></description></item>
34+
/// <item><description><c>{ "record", "class" }</c></description></item>
35+
/// <item><description><c>{ "record", "struct" }</c></description></item>
36+
/// <item><description><c>{ "sealed", "class" }</c></description></item>
37+
/// <item><description><c>{ "sealed", "partial", "class" }</c></description></item>
38+
/// <item><description><c>{ "static", "class" }</c></description></item>
39+
/// <item><description><c>{ "static", "partial", "class" }</c></description></item>
40+
/// <item><description><c>{ "struct" }</c></description></item>
41+
/// </list>
42+
/// </returns>
43+
/// <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>
44+
public static IReadOnlyList<string> GetTypeKeywords(this BaseTypeDeclarationSyntax btds, INamedTypeSymbol sym)
45+
{
46+
// maybe it would make more sense to have a [Flags] enum (Abstract | ByValue | Concrete | Delegate | Enum | Partial | Record | Sealed) okay I've overengineered this
47+
// what about using ONLY cSym? I think that's more correct anyway as it combines partial interfaces
48+
if (btds is EnumDeclarationSyntax) return new[] { /*eds.EnumKeyword.Text*/"enum" };
49+
var tds = (TypeDeclarationSyntax) btds;
50+
List<string> keywords = new() { tds.Keyword.Text };
51+
#if true
52+
if (tds is RecordDeclarationSyntax) keywords.Add(sym.IsValueType ? "struct" : "class");
53+
#else // requires newer Roslyn
54+
if (tds is RecordDeclarationSyntax rds)
55+
{
56+
var s = rds.ClassOrStructKeyword.Text;
57+
keywords.Add(s is "" ? "class" : s);
58+
}
59+
#endif
60+
var mods = tds.Modifiers.Select(static st => st.Text).ToList();
61+
if (mods.Contains("partial")) keywords.Insert(0, "partial");
62+
if (mods.Contains("abstract")) keywords.Insert(0, "abstract");
63+
else if (mods.Contains("sealed")) keywords.Insert(0, "sealed");
64+
else if (mods.Contains("static")) keywords.Insert(0, "static");
65+
return keywords;
66+
}
67+
68+
private static ITypeSymbol? GetThrownExceptionType(this SemanticModel model, ExpressionSyntax exprSyn)
69+
=> exprSyn is ObjectCreationExpressionSyntax
70+
? model.GetTypeInfo(exprSyn).Type
71+
: null; // code reads `throw <something weird>`
72+
73+
public static ITypeSymbol? GetThrownExceptionType(this SemanticModel model, ThrowExpressionSyntax tes)
74+
=> model.GetThrownExceptionType(tes.Expression);
75+
76+
public static ITypeSymbol? GetThrownExceptionType(this SemanticModel model, ThrowStatementSyntax tss)
77+
=> model.GetThrownExceptionType(tss.Expression!);
78+
79+
public static bool Matches(this ISymbol expected, ISymbol? actual)
80+
=> SymbolEqualityComparer.Default.Equals(expected, actual);
81+
82+
public static T? FirstOrNull<T>(this IEnumerable<T> list, Func<T, bool> predicate)
83+
where T : struct
84+
{
85+
foreach (var e in list) if (predicate(e)) return e;
86+
return null;
87+
}
88+
89+
public static string FullNamespace(this ISymbol? sym)
90+
{
91+
if (sym is null) return string.Empty;
92+
var s = sym.Name;
93+
var ns = sym.ContainingNamespace;
94+
while (ns is { IsGlobalNamespace: false })
95+
{
96+
s = $"{ns.Name}.{s}";
97+
ns = ns.ContainingNamespace;
98+
}
99+
return s;
100+
}
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
namespace BizHawk.SrcGen.VIM;
2+
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text;
6+
7+
using BizHawk.Common;
8+
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
using Microsoft.CodeAnalysis.CSharp.Syntax;
12+
using Microsoft.CodeAnalysis.Text;
13+
14+
[Generator]
15+
public sealed class VIMGenerator : ISourceGenerator
16+
{
17+
private sealed class VIMGenSyntaxReceiver : ISyntaxReceiver
18+
{
19+
public readonly List<TypeDeclarationSyntax> Candidates = new();
20+
21+
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
22+
{
23+
if (syntaxNode is TypeDeclarationSyntax syn) Candidates.Add(syn);
24+
}
25+
}
26+
27+
private class ImplNotes
28+
{
29+
public readonly string BaseImplNamePrefix;
30+
31+
public readonly string InvokeCall;
32+
33+
public readonly string MethodFullName;
34+
35+
public readonly ISymbol MethodSym;
36+
37+
public readonly string ReturnType;
38+
39+
public ImplNotes(ISymbol intfSym, IMethodSymbol methodSym, AttributeData vimAttr)
40+
{
41+
string? baseImplMethodName = null;
42+
string? implsClassFullName = null;
43+
foreach (var kvp in vimAttr.NamedArguments) switch (kvp.Key)
44+
{
45+
case nameof(VirtualMethodAttribute.BaseImplMethodName):
46+
baseImplMethodName = kvp.Value.Value?.ToString();
47+
break;
48+
case nameof(VirtualMethodAttribute.ImplsClassFullName):
49+
implsClassFullName = kvp.Value.Value?.ToString();
50+
break;
51+
}
52+
if (string.IsNullOrEmpty(baseImplMethodName)) baseImplMethodName = methodSym.Name;
53+
if (string.IsNullOrEmpty(implsClassFullName)) implsClassFullName = $"{intfSym.FullNamespace()}.MethodDefaultImpls";
54+
BaseImplNamePrefix = $"{implsClassFullName}.{baseImplMethodName}";
55+
InvokeCall = $"(this{string.Concat(methodSym.Parameters.Select(static pSym => $", {pSym.Name}"))})";
56+
MethodFullName = $"{intfSym.FullNamespace()}.{methodSym.Name}({string.Join(", ", methodSym.Parameters.Select(static pSym => $"{pSym.Type.ToDisplayString()} {pSym.Name}"))})";
57+
MethodSym = methodSym;
58+
ReturnType = methodSym.ReturnType.ToDisplayString();
59+
}
60+
}
61+
62+
// private static readonly DiagnosticDescriptor DiagNoEnum = new(
63+
// id: "BHI2000",
64+
//// title: "Apply [VirtualMethod] to enums used with generator",
65+
//// messageFormat: "Matching enum should have [VirtualMethod] to enable better analysis and codegen",
66+
// title: "debug",
67+
// messageFormat: "{0}",
68+
// category: "Usage",
69+
// defaultSeverity: DiagnosticSeverity.Warning,
70+
// isEnabledByDefault: true);
71+
72+
public void Initialize(GeneratorInitializationContext context)
73+
=> context.RegisterForSyntaxNotifications(static () => new VIMGenSyntaxReceiver());
74+
75+
public void Execute(GeneratorExecutionContext context)
76+
{
77+
// void DebugMsg(Location location, string msg)
78+
// => context.ReportDiagnostic(Diagnostic.Create(DiagNoEnum, location, msg));
79+
80+
if (context.SyntaxReceiver is not VIMGenSyntaxReceiver receiver) return;
81+
82+
// boilerplate to get attr working
83+
var compilation = context.Compilation;
84+
var vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute));
85+
if (vimAttrSymbol is null)
86+
{
87+
var attributesSource = SourceText.From(typeof(VIMGenerator).Assembly.GetManifestResourceStream("BizHawk.SrcGen.VIM.VirtualMethodAttribute.cs")!, Encoding.UTF8, canBeEmbedded: true);
88+
context.AddSource("VirtualMethodAttribute.cs", attributesSource);
89+
compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(attributesSource, (CSharpParseOptions) ((CSharpCompilation) context.Compilation).SyntaxTrees[0].Options));
90+
vimAttrSymbol = compilation.GetTypeByMetadataName("BizHawk.Common." + nameof(VirtualMethodAttribute))!;
91+
}
92+
93+
Dictionary<string, List<ImplNotes>> vimDict = new();
94+
List<ImplNotes> Lookup(INamedTypeSymbol intfSym)
95+
{
96+
var fqn = intfSym.FullNamespace();
97+
if (vimDict.TryGetValue(fqn, out var implNotes)) return implNotes;
98+
// else cache miss
99+
List<ImplNotes> implNotes1 = new();
100+
foreach (var methodSym in intfSym.GetMembers())
101+
{
102+
var vimAttr = methodSym.GetAttributes().FirstOrDefault(ad => vimAttrSymbol.Matches(ad.AttributeClass));
103+
if (vimAttr is not null) implNotes1.Add(new(intfSym: intfSym, methodSym: (IMethodSymbol) methodSym, vimAttr));
104+
}
105+
return vimDict[fqn] = implNotes1;
106+
}
107+
108+
List<INamedTypeSymbol> seen = new();
109+
foreach (var tds in receiver.Candidates)
110+
{
111+
var cSym = compilation.GetSemanticModel(tds.SyntaxTree).GetDeclaredSymbol(tds)!;
112+
if (seen.Contains(cSym)) continue; // dedup partial classes
113+
seen.Add(cSym);
114+
var typeKeywords = tds.GetTypeKeywords(cSym);
115+
if (typeKeywords.Contains("enum") || typeKeywords.Contains("interface") || typeKeywords.Contains("static")) continue;
116+
117+
List<string> innerText = new();
118+
foreach (var intfSym in cSym.BaseType is not null
119+
? cSym.AllInterfaces.Except(cSym.BaseType.AllInterfaces) // superclass (or its superclass, etc.) already has the delegated base implementations of these interfaces' virtual methods
120+
: cSym.AllInterfaces)
121+
{
122+
//TODO let an interface override a superinterface's virtual method -- may need to order above enumerable somehow
123+
foreach (var method in Lookup(intfSym))
124+
{
125+
if (cSym.FindImplementationForInterfaceMember(method.MethodSym) is not null) continue; // overridden
126+
innerText.Add($@"{method.ReturnType} {method.MethodFullName}
127+
=> {method.BaseImplNamePrefix}{method.InvokeCall};"); // set up this way so I can whack a "_get"/"_set" before the '(' for virtual props
128+
}
129+
}
130+
if (innerText.Count is not 0) context.AddSource(
131+
source: $@"#nullable enable
132+
133+
namespace {cSym.ContainingNamespace.ToDisplayString()}
134+
{{
135+
public {string.Join(" ", typeKeywords)} {cSym.Name}
136+
{{
137+
{string.Join("\n\n", innerText)}
138+
}}
139+
}}
140+
",
141+
hintName: $"{cSym.Name}.VIMDelegation.cs");
142+
}
143+
}
144+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#nullable enable // for when this file is embedded
2+
3+
using System;
4+
5+
namespace BizHawk.Common
6+
{
7+
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
8+
public sealed class VirtualMethodAttribute : Attribute
9+
{
10+
/// <remarks>if unset, uses annotated method's name</remarks>
11+
public string? BaseImplMethodName { get; set; } = null;
12+
13+
/// <remarks>if unset, uses <c>$"{interfaceFullName}.MethodDefaultImpls"</c></remarks>
14+
public string? ImplsClassFullName { get; set; } = null;
15+
}
16+
}

References/BizHawk.SrcGen.VIM.dll

16 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)