|
| 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 | +} |
0 commit comments