Skip to content

Commit 7aa3753

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

28 files changed

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

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)