diff --git a/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs b/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs index 898c3ed5..42324be4 100644 --- a/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs +++ b/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs @@ -14,6 +14,11 @@ public sealed class ConsumeContextConverterGenerator : IIncrementalGenerator { const string InterfaceName = "IMessageConsumeContext"; const string InterfaceFqn = $"{InterfaceNamespace}.{InterfaceName}`1"; + // Keyword types must be emitted by their metadata names (System.String), because they cannot be qualified with global:: + static readonly SymbolDisplayFormat TypeNameFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes + ); + readonly struct KnownSymbols(INamedTypeSymbol? messageConsumeContext, INamedTypeSymbol? baseEventHandler) { public INamedTypeSymbol? MessageConsumeContext { get; } = messageConsumeContext; public INamedTypeSymbol? BaseEventHandler { get; } = baseEventHandler; @@ -142,7 +147,9 @@ static bool IsPotentialUsage(SyntaxNode node, CancellationToken _) { // so emitting switch arms by this count in descending order keeps the arm of a type ahead of the arms of its // supertypes. Types related only through generic variance or array covariance get the same count. static int GetSpecificity(ITypeSymbol symbol) { - var count = symbol.AllInterfaces.Length; + // An interface converts to object although object is not among its base types, so count it explicitly. + // It keeps object the only type with no supertypes, so its arm always goes last. + var count = symbol.AllInterfaces.Length + (symbol.TypeKind == TypeKind.Interface ? 1 : 0); for (var baseType = symbol.BaseType; baseType != null; baseType = baseType.BaseType) count++; @@ -153,12 +160,15 @@ static int GetSpecificity(ITypeSymbol symbol) { // Skip unresolved generic type parameters (e.g. T in IMessageConsumeContext) if (symbol.TypeKind == TypeKind.TypeParameter) return null; + // Skip dynamic as it can't be used in a type pattern + if (symbol.TypeKind == TypeKind.Dynamic) return null; + // Skip types that are inaccessible from module-level generated code // (e.g. private nested classes can't be referenced from the generated converter) if (!IsAccessibleFromGeneratedCode(symbol)) return null; // Use fully qualified name with global:: prefix - var name = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var name = symbol.ToDisplayString(TypeNameFormat); return name.StartsWith("global::", StringComparison.Ordinal) ? name : $"global::{name}"; } diff --git a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs index 924f4525..667da1c7 100644 --- a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs +++ b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs @@ -86,6 +86,76 @@ public static void Animals(IMessageConsumeContext> ctx) { } await Assert.That(ArmIndex(generated, "global::Foo.IEnvelope")).IsLessThan(ArmIndex(generated, "global::Foo.IEnvelope")); } + [Test] + [Arguments("string", "global::System.String")] + [Arguments("object", "global::System.Object")] + [Arguments("string[]", "global::System.String[]")] + [Arguments("System.Collections.Generic.List", "global::System.Collections.Generic.List")] + public async Task Should_emit_compilable_arm_for_keyword_message_type(string messageType, string expectedArmType) { + // Issue #593: keyword types have no namespace to qualify, and 'global::string' is not valid C# + var source = $$""" + using Eventuous.Subscriptions.Context; + + namespace Foo; + + public static class Usages { + public static void Use(IMessageConsumeContext<{{messageType}}> ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(ArmIndex(generated, expectedArmType)).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task Should_not_emit_arm_for_dynamic_message_type() { + // 'dynamic' can be neither qualified nor used in a type pattern + const string source = """ + using Eventuous; + using Eventuous.Subscriptions.Context; + + namespace Foo; + + [EventType("V1.RoomBooked")] + public record RoomBooked; + + public static class Usages { + public static void Use(IMessageConsumeContext ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(generated).DoesNotContain("dynamic"); + } + + [Test] + public async Task Should_emit_object_after_interfaces() { + // Issue #594: an interface converts to object although object is not among its base types, + // and object is discovered first here + const string source = """ + using Eventuous.Subscriptions.Context; + + namespace Foo; + + public interface IFoo; + + public static class Usages { + public static void Any(IMessageConsumeContext ctx) { } + + public static void Foo(IMessageConsumeContext ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(ArmIndex(generated, "global::Foo.IFoo")).IsLessThan(ArmIndex(generated, "global::System.Object")); + } + static int ArmIndex(string generated, string typeName) { var index = generated.IndexOf($"{typeName} =>", StringComparison.Ordinal);