Skip to content

Commit 0d457ae

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

28 files changed

+345
-3188
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Text;
4+
using Microsoft.CodeAnalysis;
5+
6+
namespace Bunit.Blazor;
7+
8+
[Generator]
9+
public class EventDispatcherExtensionGenerator : IIncrementalGenerator
10+
{
11+
// Special case mappings for event names that need custom method names
12+
private static readonly Dictionary<string, string> EventNameOverrides = new()
13+
{
14+
["ondblclick"] = "DoubleClick",
15+
["onmousewheel"] = "MouseWheel"
16+
};
17+
18+
public void Initialize(IncrementalGeneratorInitializationContext context)
19+
{
20+
// Get the compilation to access type information
21+
var compilationProvider = context.CompilationProvider;
22+
23+
context.RegisterSourceOutput(compilationProvider, (spc, compilation) =>
24+
{
25+
GenerateEventDispatchExtensions(spc, compilation);
26+
});
27+
}
28+
29+
private static void GenerateEventDispatchExtensions(SourceProductionContext context, Compilation compilation)
30+
{
31+
// Find the EventHandlerAttribute type
32+
var eventHandlerAttributeSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.EventHandlerAttribute");
33+
if (eventHandlerAttributeSymbol == null)
34+
{
35+
// EventHandlerAttribute not found, skip generation
36+
return;
37+
}
38+
39+
// Find the EventHandlers class that has all the attributes
40+
var eventHandlersSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.Web.EventHandlers");
41+
if (eventHandlersSymbol == null)
42+
{
43+
// EventHandlers class not found, skip generation
44+
return;
45+
}
46+
47+
// Extract event mappings from the EventHandlerAttribute attributes on the EventHandlers class
48+
var eventMappings = new Dictionary<string, List<(string EventName, string MethodName)>>();
49+
50+
foreach (var attribute in eventHandlersSymbol.GetAttributes())
51+
{
52+
if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, eventHandlerAttributeSymbol))
53+
continue;
54+
55+
if (attribute.ConstructorArguments.Length < 2)
56+
continue;
57+
58+
// Extract: EventHandler("eventname", typeof(EventArgsType), ...)
59+
var eventName = attribute.ConstructorArguments[0].Value?.ToString();
60+
var eventArgsType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol;
61+
62+
if (string.IsNullOrEmpty(eventName) || eventArgsType == null)
63+
continue;
64+
65+
var eventArgsTypeName = eventArgsType.Name;
66+
67+
// Generate a method name from the event name (e.g., "onclick" -> "Click")
68+
var methodName = GenerateMethodNameFromEventName(eventName);
69+
70+
if (!eventMappings.ContainsKey(eventArgsTypeName))
71+
{
72+
eventMappings[eventArgsTypeName] = new List<(string, string)>();
73+
}
74+
75+
eventMappings[eventArgsTypeName].Add((eventName, methodName));
76+
}
77+
78+
if (eventMappings.Count == 0)
79+
{
80+
// No event mappings found
81+
return;
82+
}
83+
84+
var sourceBuilder = new StringBuilder(8000);
85+
sourceBuilder.AppendLine("#nullable enable");
86+
sourceBuilder.AppendLine("using AngleSharp.Dom;");
87+
sourceBuilder.AppendLine("using Microsoft.AspNetCore.Components.Web;");
88+
sourceBuilder.AppendLine("using System.Threading.Tasks;");
89+
sourceBuilder.AppendLine();
90+
sourceBuilder.AppendLine("namespace Bunit;");
91+
sourceBuilder.AppendLine();
92+
93+
// Generate a single extension class containing all event dispatch methods
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+
// Check for override first
113+
if (EventNameOverrides.TryGetValue(eventName, out var overrideName))
114+
{
115+
return overrideName;
116+
}
117+
118+
// Remove "on" prefix if present
119+
if (eventName.StartsWith("on"))
120+
{
121+
eventName = eventName.Substring(2);
122+
}
123+
124+
if (eventName.Length == 0)
125+
{
126+
return eventName;
127+
}
128+
129+
// Convert to PascalCase by capitalizing after known separators
130+
// Handle common word boundaries in event names like "keydown", "mouseup", "pointerover", etc.
131+
var result = new StringBuilder();
132+
133+
// Known word parts that should be capitalized separately
134+
var wordBoundaries = new[]
135+
{
136+
"key", "mouse", "pointer", "touch", "drag", "drop", "focus", "blur",
137+
"click", "dbl", "context", "menu", "copy", "cut", "paste",
138+
"down", "up", "over", "out", "move", "enter", "leave", "start", "end",
139+
"cancel", "change", "input", "wheel", "got", "lost", "capture",
140+
"in", "before", "after", "load", "time", "abort", "progress", "error",
141+
"activate", "deactivate", "ended", "full", "screen", "data", "metadata",
142+
"lock", "ready", "state", "scroll", "toggle", "close", "seeking", "seeked",
143+
"loaded", "duration", "emptied", "stalled", "suspend", "volume", "waiting",
144+
"play", "playing", "pause", "press", "rate", "stop", "cue", "can", "through", "update"
145+
};
146+
147+
int i = 0;
148+
while (i < eventName.Length)
149+
{
150+
bool foundWord = false;
151+
152+
// Try to match the longest word boundary at current position
153+
foreach (var word in wordBoundaries.OrderByDescending(w => w.Length))
154+
{
155+
if (i + word.Length <= eventName.Length &&
156+
eventName.Substring(i, word.Length).Equals(word, System.StringComparison.OrdinalIgnoreCase))
157+
{
158+
// Capitalize first letter of the word
159+
result.Append(char.ToUpper(word[0]));
160+
result.Append(word.Substring(1).ToLower());
161+
i += word.Length;
162+
foundWord = true;
163+
break;
164+
}
165+
}
166+
167+
if (!foundWord)
168+
{
169+
// No word boundary found, just capitalize if it's the first character
170+
result.Append(i == 0 ? char.ToUpper(eventName[i]) : eventName[i]);
171+
i++;
172+
}
173+
}
174+
175+
return result.ToString();
176+
}
177+
178+
private static void GenerateExtensionsForEventArgsType(StringBuilder sourceBuilder, string eventArgsType, List<(string EventName, string MethodName)> events)
179+
{
180+
// Add a comment section for this event args type
181+
sourceBuilder.AppendLine($"\t// {eventArgsType} events");
182+
183+
foreach (var (eventName, methodName) in events)
184+
{
185+
// Get the fully qualified type name if needed (for ErrorEventArgs ambiguity)
186+
var qualifiedEventArgsType = eventArgsType == "ErrorEventArgs"
187+
? "Microsoft.AspNetCore.Components.Web.ErrorEventArgs"
188+
: eventArgsType;
189+
190+
// Special handling for Click and DoubleClick to set Detail property
191+
if (methodName == "Click")
192+
{
193+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 1 }");
194+
}
195+
else if (methodName == "DoubleClick")
196+
{
197+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 2 }");
198+
}
199+
else
200+
{
201+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, $"new {qualifiedEventArgsType}()");
202+
}
203+
}
204+
205+
sourceBuilder.AppendLine();
206+
}
207+
208+
private static void GenerateAsyncEventMethodWithDefaultArgs(StringBuilder sourceBuilder, string methodName, string eventName, string eventArgsType, string defaultArgs)
209+
{
210+
sourceBuilder.AppendLine("\t/// <summary>");
211+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
212+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
213+
sourceBuilder.AppendLine("\t/// </summary>");
214+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
215+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
216+
sourceBuilder.AppendLine($"\tpublic static void {methodName}(this IElement element, {eventArgsType}? eventArgs = null) => _ = {methodName}Async(element, eventArgs);");
217+
sourceBuilder.AppendLine();
218+
219+
sourceBuilder.AppendLine("\t/// <summary>");
220+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
221+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
222+
sourceBuilder.AppendLine("\t/// </summary>");
223+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
224+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
225+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
226+
sourceBuilder.AppendLine($"\tpublic static Task {methodName}Async(this IElement element, {eventArgsType}? eventArgs = null) => element.TriggerEventAsync(\"{eventName}\", eventArgs ?? {defaultArgs});");
227+
sourceBuilder.AppendLine();
228+
229+
sourceBuilder.AppendLine("\t/// <summary>");
230+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on the element returned by <paramref name=\"elementTask\"/>, passing the provided <paramref name=\"eventArgs\"/>");
231+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
232+
sourceBuilder.AppendLine("\t/// </summary>");
233+
sourceBuilder.AppendLine("\t/// <param name=\"elementTask\">A task that returns the element to raise the element on.</param>");
234+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
235+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
236+
sourceBuilder.AppendLine($"\tpublic static async Task {methodName}Async(this Task<IElement> elementTask, {eventArgsType}? eventArgs = null)");
237+
sourceBuilder.AppendLine("\t{");
238+
sourceBuilder.AppendLine("\t\tvar element = await elementTask;");
239+
sourceBuilder.AppendLine($"\t\tawait {methodName}Async(element, eventArgs);");
240+
sourceBuilder.AppendLine("\t}");
241+
sourceBuilder.AppendLine();
242+
}
243+
}

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.

0 commit comments

Comments
 (0)