Skip to content

Commit 3d63504

Browse files
committed
feat: Use source generator for event dispatch extensions
1 parent 715c554 commit 3d63504

28 files changed

+329
-3213
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
using System;
2+
using System.Collections.Frozen;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text;
6+
using Microsoft.CodeAnalysis;
7+
8+
namespace Bunit.Blazor;
9+
10+
[Generator]
11+
public class EventDispatcherExtensionGenerator : IIncrementalGenerator
12+
{
13+
private static readonly Dictionary<string, string> EventNameOverrides = new()
14+
{
15+
["ondblclick"] = "DoubleClick",
16+
["onmousewheel"] = "MouseWheel"
17+
};
18+
19+
private static readonly FrozenSet<string> WordBoundaries =
20+
[
21+
"key", "mouse", "pointer", "touch", "drag", "drop", "focus", "blur",
22+
"click", "dbl", "context", "menu", "copy", "cut", "paste",
23+
"down", "up", "over", "out", "move", "enter", "leave", "start", "end",
24+
"cancel", "change", "input", "wheel", "got", "lost", "capture",
25+
"in", "before", "after", "load", "time", "abort", "progress", "error",
26+
"activate", "deactivate", "ended", "full", "screen", "data", "metadata",
27+
"lock", "ready", "state", "scroll", "toggle", "close", "seeking", "seeked",
28+
"loaded", "duration", "emptied", "stalled", "suspend", "volume", "waiting",
29+
"play", "playing", "pause", "press", "rate", "stop", "cue", "can", "through", "update"
30+
];
31+
32+
public void Initialize(IncrementalGeneratorInitializationContext context)
33+
{
34+
var compilationProvider = context.CompilationProvider;
35+
36+
context.RegisterSourceOutput(compilationProvider, GenerateEventDispatchExtensions);
37+
}
38+
39+
private static void GenerateEventDispatchExtensions(SourceProductionContext context, Compilation compilation)
40+
{
41+
var eventHandlerAttributeSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.EventHandlerAttribute");
42+
if (eventHandlerAttributeSymbol is null)
43+
{
44+
return;
45+
}
46+
47+
var eventHandlersSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.Web.EventHandlers");
48+
if (eventHandlersSymbol is null)
49+
{
50+
return;
51+
}
52+
53+
var eventMappings = new Dictionary<string, List<(string EventName, string MethodName)>>();
54+
55+
foreach (var attribute in eventHandlersSymbol.GetAttributes())
56+
{
57+
if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, eventHandlerAttributeSymbol))
58+
continue;
59+
60+
if (attribute.ConstructorArguments.Length < 2)
61+
continue;
62+
63+
var eventName = attribute.ConstructorArguments[0].Value?.ToString();
64+
var eventArgsType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol;
65+
66+
if (string.IsNullOrEmpty(eventName) || eventArgsType == null)
67+
continue;
68+
69+
var eventArgsTypeName = eventArgsType.Name;
70+
71+
var methodName = GenerateMethodNameFromEventName(eventName);
72+
73+
if (!eventMappings.ContainsKey(eventArgsTypeName))
74+
{
75+
eventMappings[eventArgsTypeName] = [];
76+
}
77+
78+
eventMappings[eventArgsTypeName].Add((eventName, methodName));
79+
}
80+
81+
if (eventMappings.Count == 0)
82+
{
83+
return;
84+
}
85+
86+
var sourceBuilder = new StringBuilder(8000);
87+
sourceBuilder.AppendLine("#nullable enable");
88+
sourceBuilder.AppendLine("using AngleSharp.Dom;");
89+
sourceBuilder.AppendLine("using Microsoft.AspNetCore.Components.Web;");
90+
sourceBuilder.AppendLine("using System.Threading.Tasks;");
91+
sourceBuilder.AppendLine();
92+
sourceBuilder.AppendLine("namespace Bunit;");
93+
sourceBuilder.AppendLine();
94+
95+
sourceBuilder.AppendLine("/// <summary>");
96+
sourceBuilder.AppendLine("/// Event dispatch helper extension methods.");
97+
sourceBuilder.AppendLine("/// </summary>");
98+
sourceBuilder.AppendLine("public static partial class EventHandlerDispatchExtensions");
99+
sourceBuilder.AppendLine("{");
100+
101+
foreach (var kvp in eventMappings.OrderBy(x => x.Key))
102+
{
103+
GenerateExtensionsForEventArgsType(sourceBuilder, kvp.Key, kvp.Value);
104+
}
105+
106+
sourceBuilder.AppendLine("}");
107+
108+
context.AddSource("EventHandlerDispatchExtensions.g.cs", sourceBuilder.ToString());
109+
}
110+
111+
private static string GenerateMethodNameFromEventName(string eventName)
112+
{
113+
if (EventNameOverrides.TryGetValue(eventName, out var overrideName))
114+
{
115+
return overrideName;
116+
}
117+
118+
if (eventName.StartsWith("on"))
119+
{
120+
eventName = eventName[2..];
121+
}
122+
123+
if (eventName.Length == 0)
124+
{
125+
return eventName;
126+
}
127+
128+
var result = new StringBuilder();
129+
130+
var i = 0;
131+
while (i < eventName.Length)
132+
{
133+
var foundWord = false;
134+
135+
foreach (var word in WordBoundaries.OrderByDescending(w => w.Length))
136+
{
137+
if (i + word.Length <= eventName.Length &&
138+
eventName.AsSpan(i, word.Length).Equals(word.AsSpan(), StringComparison.OrdinalIgnoreCase))
139+
{
140+
result.Append(char.ToUpper(word[0]));
141+
result.Append(word[1..].ToLower());
142+
i += word.Length;
143+
foundWord = true;
144+
break;
145+
}
146+
}
147+
148+
if (!foundWord)
149+
{
150+
result.Append(i == 0 ? char.ToUpper(eventName[i]) : eventName[i]);
151+
i++;
152+
}
153+
}
154+
155+
return result.ToString();
156+
}
157+
158+
private static void GenerateExtensionsForEventArgsType(StringBuilder sourceBuilder, string eventArgsType, List<(string EventName, string MethodName)> events)
159+
{
160+
sourceBuilder.AppendLine($"\t// {eventArgsType} events");
161+
162+
foreach (var (eventName, methodName) in events)
163+
{
164+
var qualifiedEventArgsType = eventArgsType == "ErrorEventArgs"
165+
? "Microsoft.AspNetCore.Components.Web.ErrorEventArgs"
166+
: eventArgsType;
167+
168+
if (methodName == "Click")
169+
{
170+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 1 }");
171+
}
172+
else if (methodName == "DoubleClick")
173+
{
174+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 2 }");
175+
}
176+
else
177+
{
178+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, $"new {qualifiedEventArgsType}()");
179+
}
180+
}
181+
}
182+
183+
private static void GenerateAsyncEventMethodWithDefaultArgs(StringBuilder sourceBuilder, string methodName, string eventName, string eventArgsType, string defaultArgs)
184+
{
185+
sourceBuilder.AppendLine("\t/// <summary>");
186+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
187+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
188+
sourceBuilder.AppendLine("\t/// </summary>");
189+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
190+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
191+
sourceBuilder.AppendLine($"\tpublic static void {methodName}(this IElement element, {eventArgsType}? eventArgs = null) => _ = {methodName}Async(element, eventArgs);");
192+
sourceBuilder.AppendLine();
193+
194+
sourceBuilder.AppendLine("\t/// <summary>");
195+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
196+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
197+
sourceBuilder.AppendLine("\t/// </summary>");
198+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
199+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
200+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
201+
sourceBuilder.AppendLine($"\tpublic static Task {methodName}Async(this IElement element, {eventArgsType}? eventArgs = null) => element.TriggerEventAsync(\"{eventName}\", eventArgs ?? {defaultArgs});");
202+
sourceBuilder.AppendLine();
203+
204+
sourceBuilder.AppendLine("\t/// <summary>");
205+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on the element returned by <paramref name=\"elementTask\"/>, passing the provided <paramref name=\"eventArgs\"/>");
206+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
207+
sourceBuilder.AppendLine("\t/// </summary>");
208+
sourceBuilder.AppendLine("\t/// <param name=\"elementTask\">A task that returns the element to raise the element on.</param>");
209+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
210+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
211+
sourceBuilder.AppendLine($"\tpublic static async Task {methodName}Async(this Task<IElement> elementTask, {eventArgsType}? eventArgs = null)");
212+
sourceBuilder.AppendLine("\t{");
213+
sourceBuilder.AppendLine("\t\tvar element = await elementTask;");
214+
sourceBuilder.AppendLine($"\t\tawait {methodName}Async(element, eventArgs);");
215+
sourceBuilder.AppendLine("\t}");
216+
sourceBuilder.AppendLine();
217+
}
218+
}

src/bunit.generators.internal/bunit.generators.internal.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@
2121
<PackageReference Include="Meziantou.Polyfill" PrivateAssets="all" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
2222
</ItemGroup>
2323

24+
<ItemGroup>
25+
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers\dotnet\cs\$(AssemblyName).dll" Visible="false" />
26+
</ItemGroup>
27+
2428
</Project>

src/bunit/EventDispatchExtensions/ClipboardEventDispatchExtensions.cs

Lines changed: 0 additions & 129 deletions
This file was deleted.

src/bunit/EventDispatchExtensions/DetailsElementEventDispatchExtensions.cs

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)