425 lines
18 KiB
C#
425 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
|
|
namespace CloakBrowser.Generators;
|
|
|
|
/// <summary>
|
|
/// Roslyn source generator that auto-implements the boilerplate "delegate everything
|
|
/// to the wrapped object" members for a decorator class.
|
|
///
|
|
/// Usage: annotate a <c>partial class</c> with
|
|
/// <c>[GenerateInterfaceDelegation(typeof(IPage))]</c> and provide a field/property
|
|
/// named <c>_inner</c> (or the name passed to the attribute) of the interface type.
|
|
/// The generator emits delegating implementations for every interface member the
|
|
/// class does NOT already declare itself. Members you declare manually (the
|
|
/// "intercepted" / humanized ones) are left untouched.
|
|
///
|
|
/// This lets us hand-write only the handful of methods we want to humanize while the
|
|
/// remaining ~hundreds of interface members are forwarded verbatim with full static
|
|
/// typing, correct async signatures, optional parameters, and no reflection.
|
|
/// </summary>
|
|
[Generator(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
|
public sealed class InterfaceDelegationGenerator : IIncrementalGenerator
|
|
{
|
|
private const string AttributeNamespace = "CloakBrowser.Wrappers";
|
|
private const string AttributeName = "GenerateInterfaceDelegationAttribute";
|
|
private const string AttributeFullName = AttributeNamespace + "." + AttributeName;
|
|
|
|
private const string AttributeSource = @"// <auto-generated/>
|
|
#nullable enable
|
|
namespace CloakBrowser.Wrappers
|
|
{
|
|
/// <summary>
|
|
/// Marks a partial decorator class for automatic generation of delegating members
|
|
/// for the given interface. The class must expose a backing member (default name
|
|
/// <c>_inner</c>) of the interface type that calls are forwarded to.
|
|
/// </summary>
|
|
[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
|
internal sealed class GenerateInterfaceDelegationAttribute : System.Attribute
|
|
{
|
|
public GenerateInterfaceDelegationAttribute(System.Type interfaceType, string innerMemberName = ""_inner"")
|
|
{
|
|
InterfaceType = interfaceType;
|
|
InnerMemberName = innerMemberName;
|
|
}
|
|
|
|
public System.Type InterfaceType { get; }
|
|
public string InnerMemberName { get; }
|
|
}
|
|
}
|
|
";
|
|
|
|
public void Initialize(IncrementalGeneratorInitializationContext context)
|
|
{
|
|
// Emit the marker attribute into the consuming compilation.
|
|
context.RegisterPostInitializationOutput(ctx =>
|
|
ctx.AddSource("GenerateInterfaceDelegationAttribute.g.cs",
|
|
SourceText.From(AttributeSource, Encoding.UTF8)));
|
|
|
|
var candidates = context.SyntaxProvider
|
|
.CreateSyntaxProvider(
|
|
predicate: static (node, _) =>
|
|
node is ClassDeclarationSyntax c &&
|
|
c.AttributeLists.Count > 0 &&
|
|
c.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)),
|
|
transform: static (ctx, _) => (INamedTypeSymbol?)ctx.SemanticModel.GetDeclaredSymbol(ctx.Node))
|
|
.Where(static s => s is not null)
|
|
.Select(static (s, _) => s!);
|
|
|
|
var compilationAndClasses = context.CompilationProvider.Combine(candidates.Collect());
|
|
|
|
context.RegisterSourceOutput(compilationAndClasses, static (spc, source) =>
|
|
Execute(spc, source.Left, source.Right));
|
|
}
|
|
|
|
private static void Execute(SourceProductionContext spc, Compilation compilation,
|
|
System.Collections.Immutable.ImmutableArray<INamedTypeSymbol> classes)
|
|
{
|
|
var attrSymbol = compilation.GetTypeByMetadataName(AttributeFullName);
|
|
if (attrSymbol is null)
|
|
return;
|
|
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var cls in classes.Distinct(SymbolEqualityComparer.Default).Cast<INamedTypeSymbol>())
|
|
{
|
|
var attrs = cls.GetAttributes()
|
|
.Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attrSymbol))
|
|
.ToList();
|
|
if (attrs.Count == 0)
|
|
continue;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("// <auto-generated/>");
|
|
sb.AppendLine("#nullable enable");
|
|
sb.AppendLine("#pragma warning disable CS0108 // member hides inherited member");
|
|
sb.AppendLine("#pragma warning disable CS0612 // member is obsolete (delegation is intentional)");
|
|
sb.AppendLine("#pragma warning disable CS0618 // member is obsolete (delegation is intentional)");
|
|
sb.AppendLine();
|
|
|
|
string? ns = cls.ContainingNamespace.IsGlobalNamespace
|
|
? null
|
|
: cls.ContainingNamespace.ToDisplayString();
|
|
if (ns is not null)
|
|
{
|
|
sb.Append("namespace ").Append(ns).AppendLine();
|
|
sb.AppendLine("{");
|
|
}
|
|
|
|
sb.Append(" partial class ").Append(cls.Name).AppendLine();
|
|
sb.AppendLine(" {");
|
|
|
|
// Collect members the class already declares (by signature) so we don't
|
|
// re-emit (and conflict with) hand-written intercepted methods.
|
|
var declared = BuildDeclaredSignatureSet(cls);
|
|
|
|
foreach (var attr in attrs)
|
|
{
|
|
if (attr.ConstructorArguments.Length < 1)
|
|
continue;
|
|
if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol iface)
|
|
continue;
|
|
string innerName = attr.ConstructorArguments.Length >= 2 &&
|
|
attr.ConstructorArguments[1].Value is string s
|
|
? s
|
|
: "_inner";
|
|
|
|
EmitForInterface(sb, iface, innerName, declared);
|
|
}
|
|
|
|
sb.AppendLine(" }");
|
|
if (ns is not null)
|
|
sb.AppendLine("}");
|
|
|
|
string hint = (ns is null ? "" : ns + ".") + cls.Name + ".Delegation.g.cs";
|
|
// De-dup hint names across partials.
|
|
if (!seen.Add(hint))
|
|
hint = hint + "." + Guid.NewGuid().ToString("N").Substring(0, 6);
|
|
spc.AddSource(hint, SourceText.From(sb.ToString(), Encoding.UTF8));
|
|
}
|
|
}
|
|
|
|
private static HashSet<string> BuildDeclaredSignatureSet(INamedTypeSymbol cls)
|
|
{
|
|
var set = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var m in cls.GetMembers())
|
|
{
|
|
switch (m)
|
|
{
|
|
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
|
set.Add(MethodSignature(method));
|
|
break;
|
|
case IPropertySymbol prop when prop.IsIndexer:
|
|
set.Add(IndexerSignature(prop));
|
|
break;
|
|
case IPropertySymbol prop:
|
|
set.Add("prop:" + prop.Name);
|
|
break;
|
|
case IEventSymbol evt:
|
|
set.Add("event:" + evt.Name);
|
|
break;
|
|
}
|
|
}
|
|
return set;
|
|
}
|
|
|
|
private static void EmitForInterface(StringBuilder sb, INamedTypeSymbol iface,
|
|
string innerName, HashSet<string> declared)
|
|
{
|
|
// Walk the interface and every interface it inherits.
|
|
var allIfaces = new List<INamedTypeSymbol> { iface };
|
|
allIfaces.AddRange(iface.AllInterfaces);
|
|
|
|
var emitted = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
foreach (var i in allIfaces)
|
|
{
|
|
foreach (var member in i.GetMembers())
|
|
{
|
|
switch (member)
|
|
{
|
|
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
|
{
|
|
string sig = MethodSignature(method);
|
|
if (declared.Contains(sig) || !emitted.Add(sig))
|
|
continue;
|
|
EmitMethod(sb, method, innerName);
|
|
break;
|
|
}
|
|
case IPropertySymbol prop when prop.IsIndexer:
|
|
{
|
|
string sig = IndexerSignature(prop);
|
|
if (declared.Contains(sig) || !emitted.Add(sig))
|
|
continue;
|
|
EmitIndexer(sb, prop, innerName);
|
|
break;
|
|
}
|
|
case IPropertySymbol prop:
|
|
{
|
|
string key = "prop:" + prop.Name;
|
|
if (declared.Contains(key) || !emitted.Add(key))
|
|
continue;
|
|
EmitProperty(sb, prop, innerName);
|
|
break;
|
|
}
|
|
case IEventSymbol evt:
|
|
{
|
|
string key = "event:" + evt.Name;
|
|
if (declared.Contains(key) || !emitted.Add(key))
|
|
continue;
|
|
EmitEvent(sb, evt, innerName);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Emitters
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Marker on every generated (delegating) member, so consumers/tests can tell a
|
|
// generator-delegated member apart from a hand-written (intercepted) one.
|
|
private const string GenAttr =
|
|
" [global::System.CodeDom.Compiler.GeneratedCode(\"CloakBrowser.Generators\", \"1.0\")]";
|
|
|
|
private static void EmitMethod(StringBuilder sb, IMethodSymbol method, string innerName)
|
|
{
|
|
string ret = method.ReturnType.ToDisplayString(FullyQualified);
|
|
string retKeyword = method.ReturnsVoid ? "void" : ret;
|
|
|
|
var pars = new List<string>();
|
|
var args = new List<string>();
|
|
foreach (var p in method.Parameters)
|
|
BuildParam(p, pars, args);
|
|
|
|
string typeParams = method.IsGenericMethod
|
|
? "<" + string.Join(", ", method.TypeParameters.Select(t => t.Name)) + ">"
|
|
: "";
|
|
string constraints = BuildConstraints(method);
|
|
|
|
sb.AppendLine(GenAttr);
|
|
sb.Append(" public ")
|
|
.Append(retKeyword).Append(' ')
|
|
.Append(method.Name).Append(typeParams).Append('(')
|
|
.Append(string.Join(", ", pars)).Append(')')
|
|
.Append(constraints)
|
|
.Append(" => ")
|
|
.Append(innerName).Append('.').Append(method.Name).Append(typeParams)
|
|
.Append('(').Append(string.Join(", ", args)).Append(");")
|
|
.AppendLine();
|
|
}
|
|
|
|
private static void EmitProperty(StringBuilder sb, IPropertySymbol prop, string innerName)
|
|
{
|
|
string type = prop.Type.ToDisplayString(FullyQualified);
|
|
sb.AppendLine(GenAttr);
|
|
sb.Append(" public ").Append(type).Append(' ').Append(prop.Name).Append(" { ");
|
|
if (prop.GetMethod is not null)
|
|
sb.Append("get => ").Append(innerName).Append('.').Append(prop.Name).Append("; ");
|
|
if (prop.SetMethod is not null)
|
|
sb.Append("set => ").Append(innerName).Append('.').Append(prop.Name).Append(" = value; ");
|
|
sb.AppendLine("}");
|
|
}
|
|
|
|
private static void EmitIndexer(StringBuilder sb, IPropertySymbol prop, string innerName)
|
|
{
|
|
string type = prop.Type.ToDisplayString(FullyQualified);
|
|
var pars = new List<string>();
|
|
var args = new List<string>();
|
|
foreach (var p in prop.Parameters)
|
|
BuildParam(p, pars, args);
|
|
|
|
sb.AppendLine(GenAttr);
|
|
sb.Append(" public ").Append(type).Append(" this[")
|
|
.Append(string.Join(", ", pars)).Append("] { ");
|
|
if (prop.GetMethod is not null)
|
|
sb.Append("get => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("]; ");
|
|
if (prop.SetMethod is not null)
|
|
sb.Append("set => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("] = value; ");
|
|
sb.AppendLine("}");
|
|
}
|
|
|
|
private static void EmitEvent(StringBuilder sb, IEventSymbol evt, string innerName)
|
|
{
|
|
string type = evt.Type.ToDisplayString(FullyQualified);
|
|
sb.AppendLine(GenAttr);
|
|
sb.Append(" public event ").Append(type).Append(' ').Append(evt.Name).AppendLine();
|
|
sb.AppendLine(" {");
|
|
sb.Append(" add => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" += value;");
|
|
sb.Append(" remove => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" -= value;");
|
|
sb.AppendLine(" }");
|
|
}
|
|
|
|
private static void BuildParam(IParameterSymbol p, List<string> pars, List<string> args)
|
|
{
|
|
string refKind = p.RefKind switch
|
|
{
|
|
RefKind.Ref => "ref ",
|
|
RefKind.Out => "out ",
|
|
RefKind.In => "in ",
|
|
_ => "",
|
|
};
|
|
string ptype = p.Type.ToDisplayString(FullyQualified);
|
|
// Special types (object, string, ...) sometimes drop their nullable annotation
|
|
// in the display string; re-attach it so `object? arg = null` overrides match
|
|
// and we don't trip CS8625.
|
|
if (p.NullableAnnotation == NullableAnnotation.Annotated &&
|
|
!p.Type.IsValueType && !ptype.EndsWith("?"))
|
|
{
|
|
ptype += "?";
|
|
}
|
|
string pname = SafeName(p.Name);
|
|
|
|
string paramsKw = p.IsParams ? "params " : "";
|
|
string decl = paramsKw + refKind + ptype + " " + pname;
|
|
|
|
if (p.HasExplicitDefaultValue)
|
|
decl += " = " + FormatDefault(p);
|
|
|
|
pars.Add(decl);
|
|
args.Add(refKind + pname);
|
|
}
|
|
|
|
private static string FormatDefault(IParameterSymbol p)
|
|
{
|
|
var v = p.ExplicitDefaultValue;
|
|
if (v is null)
|
|
{
|
|
// For a null default, value types use the literal `default`; reference
|
|
// types use `default!` so we never trip CS8625 regardless of whether the
|
|
// interface was compiled in a nullable-aware context.
|
|
return p.Type.IsValueType ? "default" : "default!";
|
|
}
|
|
return v switch
|
|
{
|
|
bool b => b ? "true" : "false",
|
|
string s => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
|
|
char c => "'" + c + "'",
|
|
float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture) + "f",
|
|
double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
_ when p.Type.TypeKind == TypeKind.Enum =>
|
|
"(" + p.Type.ToDisplayString(FullyQualified) + ")" + v,
|
|
_ => System.Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "default",
|
|
};
|
|
}
|
|
|
|
private static string BuildConstraints(IMethodSymbol method)
|
|
{
|
|
if (!method.IsGenericMethod)
|
|
return "";
|
|
var sb = new StringBuilder();
|
|
foreach (var tp in method.TypeParameters)
|
|
{
|
|
var parts = new List<string>();
|
|
if (tp.HasReferenceTypeConstraint) parts.Add("class");
|
|
if (tp.HasValueTypeConstraint) parts.Add("struct");
|
|
if (tp.HasNotNullConstraint) parts.Add("notnull");
|
|
if (tp.HasUnmanagedTypeConstraint) parts.Add("unmanaged");
|
|
foreach (var ct in tp.ConstraintTypes)
|
|
parts.Add(ct.ToDisplayString(FullyQualified));
|
|
if (tp.HasConstructorConstraint) parts.Add("new()");
|
|
if (parts.Count > 0)
|
|
sb.Append(" where ").Append(tp.Name).Append(" : ").Append(string.Join(", ", parts));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Signature helpers (must match how we detect already-declared members)
|
|
// -----------------------------------------------------------------------
|
|
|
|
private static string MethodSignature(IMethodSymbol m)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append(m.Name);
|
|
sb.Append('`').Append(m.TypeParameters.Length);
|
|
sb.Append('(');
|
|
sb.Append(string.Join(",", m.Parameters.Select(p =>
|
|
(p.RefKind == RefKind.None ? "" : p.RefKind.ToString().ToLowerInvariant() + " ")
|
|
+ p.Type.ToDisplayString(SignatureFormat))));
|
|
sb.Append(')');
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string IndexerSignature(IPropertySymbol p)
|
|
{
|
|
return "this[" + string.Join(",", p.Parameters.Select(x =>
|
|
x.Type.ToDisplayString(SignatureFormat))) + "]";
|
|
}
|
|
|
|
private static string SafeName(string name)
|
|
{
|
|
return SyntaxFactsKeywords.Contains(name) ? "@" + name : name;
|
|
}
|
|
|
|
private static readonly HashSet<string> SyntaxFactsKeywords = new(StringComparer.Ordinal)
|
|
{
|
|
"abstract","as","base","bool","break","byte","case","catch","char","checked","class","const",
|
|
"continue","decimal","default","delegate","do","double","else","enum","event","explicit","extern",
|
|
"false","finally","fixed","float","for","foreach","goto","if","implicit","in","int","interface",
|
|
"internal","is","lock","long","namespace","new","null","object","operator","out","override","params",
|
|
"private","protected","public","readonly","ref","return","sbyte","sealed","short","sizeof","stackalloc",
|
|
"static","string","struct","switch","this","throw","true","try","typeof","uint","ulong","unchecked",
|
|
"unsafe","ushort","using","virtual","void","volatile","while",
|
|
};
|
|
|
|
private static readonly SymbolDisplayFormat FullyQualified =
|
|
SymbolDisplayFormat.FullyQualifiedFormat
|
|
.WithMiscellaneousOptions(
|
|
SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
|
|
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier |
|
|
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
|
|
|
|
// For signature comparison we want stable type names without nullable annotations,
|
|
// so the manual override matches the interface member regardless of NRT modifiers.
|
|
private static readonly SymbolDisplayFormat SignatureFormat =
|
|
SymbolDisplayFormat.FullyQualifiedFormat
|
|
.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
|
|
}
|