From d3f9b67c43f87340626c15c50bb2898c1dba897a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 31 Oct 2026 08:42:06 +0100 Subject: [PATCH 1/2] fix: support mocking types from extern-aliased references Types from a reference that is only reachable through an extern alias do not resolve under `global::`, so the generated mock failed to compile (CS0400). The generator now qualifies such types with their alias and declares it in the generated file. --- .../Entities/Attribute.cs | 2 +- .../Entities/AttributeParameter.cs | 8 +- .../Entities/Class.cs | 2 +- .../Entities/Event.cs | 2 +- .../Entities/Method.cs | 2 +- .../Entities/MethodParameter.cs | 2 +- .../Entities/Property.cs | 2 +- .../Entities/Type.cs | 2 +- Source/Mockolate.SourceGenerators/Helpers.cs | 31 +++++ .../Internals/EntityCache.cs | 48 ++++++- .../MockGenerator.cs | 51 +++++-- .../Sources/Sources.cs | 22 +-- .../MockTests.CrossAssemblyTests.cs | 125 ++++++++++++++++++ 13 files changed, 266 insertions(+), 33 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Entities/Attribute.cs b/Source/Mockolate.SourceGenerators/Entities/Attribute.cs index 7c0ce044e..54b96854c 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Attribute.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Attribute.cs @@ -6,7 +6,7 @@ internal record Attribute { public Attribute(AttributeData attributeData) { - Name = ToName(attributeData.AttributeClass?.ToDisplayString(Helpers.TypeDisplayFormat) ?? ""); + Name = ToName(attributeData.AttributeClass?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat) ?? ""); if (attributeData.ConstructorArguments.Length > 0) { Parameters = new EquatableArray(attributeData.ConstructorArguments diff --git a/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs b/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs index 4dbf8a517..e61958079 100644 --- a/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs +++ b/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs @@ -9,15 +9,15 @@ public AttributeParameter(TypedConstant value) { if (value.Kind == TypedConstantKind.Array) { - Value = $"new {value.Type}{{{string.Join(", ", value.Values.Select(SerializeConstantValue))}}}"; + Value = $"new {value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}{{{string.Join(", ", value.Values.Select(SerializeConstantValue))}}}"; } - else if (value.Kind == TypedConstantKind.Type) + else if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol type) { - Value = $"typeof({value.Value})"; + Value = $"typeof({type.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)})"; } else if (value.Kind == TypedConstantKind.Enum) { - Value = $"({value.Type?.ToDisplayString(Helpers.TypeDisplayFormat)}){value.Value}"; + Value = $"({value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}){value.Value}"; } else { diff --git a/Source/Mockolate.SourceGenerators/Entities/Class.cs b/Source/Mockolate.SourceGenerators/Entities/Class.cs index 972864afe..da94c9351 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Class.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Class.cs @@ -28,7 +28,7 @@ public Class(ITypeSymbol type, #pragma warning restore S107 { _sourceAssembly = sourceAssembly; - ClassFullName = type.ToDisplayString(Helpers.TypeDisplayFormat); + ClassFullName = type.ToQualifiedDisplayString(Helpers.TypeDisplayFormat); ClassName = GetTypeName(type); DisplayString = GetTypeFullName(type); diff --git a/Source/Mockolate.SourceGenerators/Entities/Event.cs b/Source/Mockolate.SourceGenerators/Entities/Event.cs index c2b70ea11..03efecd66 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Event.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Event.cs @@ -17,7 +17,7 @@ public Event(IEventSymbol eventSymbol, IMethodSymbol delegateInvokeMethod, List< IsAbstract = eventSymbol.IsAbstract; Name = Helpers.EscapeIfKeyword(eventSymbol.ExplicitInterfaceImplementations.Length > 0 ? eventSymbol.ExplicitInterfaceImplementations[0].Name : eventSymbol.Name); Type = Type.From(eventSymbol.Type); - ContainingType = eventSymbol.ContainingType.ToDisplayString(Helpers.TypeDisplayFormat); + ContainingType = eventSymbol.ContainingType.ToQualifiedDisplayString(Helpers.TypeDisplayFormat); DeclaredContainingType = ContainingType; Delegate = new Method(delegateInvokeMethod, null, sourceAssembly); Attributes = eventSymbol.GetAttributes().ToAttributeArray(sourceAssembly); diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index da1b88bb3..356180909 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -22,7 +22,7 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I IsDelegateInvoke = methodSymbol.MethodKind == MethodKind.DelegateInvoke; ReturnType = methodSymbol.ReturnsVoid ? Type.Void : Type.From(methodSymbol.ReturnType); Name = Helpers.EscapeIfKeyword(methodSymbol.ExplicitInterfaceImplementations.Length > 0 ? methodSymbol.ExplicitInterfaceImplementations[0].Name : methodSymbol.Name); - ContainingType = methodSymbol.ContainingType.ToDisplayString(Helpers.TypeDisplayFormat); + ContainingType = methodSymbol.ContainingType.ToQualifiedDisplayString(Helpers.TypeDisplayFormat); DeclaredContainingType = ContainingType; Parameters = new EquatableArray( methodSymbol.Parameters.Select(MethodParameter.From).ToArray()); diff --git a/Source/Mockolate.SourceGenerators/Entities/MethodParameter.cs b/Source/Mockolate.SourceGenerators/Entities/MethodParameter.cs index d03337f71..cecff5faa 100644 --- a/Source/Mockolate.SourceGenerators/Entities/MethodParameter.cs +++ b/Source/Mockolate.SourceGenerators/Entities/MethodParameter.cs @@ -25,7 +25,7 @@ public MethodParameter(IParameterSymbol parameterSymbol) } else if (parameterSymbol.Type.TypeKind == TypeKind.Enum) { - string enumTypeName = parameterSymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + string enumTypeName = parameterSymbol.Type.ToQualifiedDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); ExplicitDefaultValue = "(" + enumTypeName + ")" + explicitDefaultValue; } else diff --git a/Source/Mockolate.SourceGenerators/Entities/Property.cs b/Source/Mockolate.SourceGenerators/Entities/Property.cs index 14eeba206..5d9a2fc93 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Property.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Property.cs @@ -15,7 +15,7 @@ public Property(IPropertySymbol propertySymbol, List? alreadyDefinedPr string rawName = propertySymbol.ExplicitInterfaceImplementations.Length > 0 ? propertySymbol.ExplicitInterfaceImplementations[0].Name : propertySymbol.Name; Name = propertySymbol.IsIndexer ? rawName : Helpers.EscapeIfKeyword(rawName); Type = Type.From(propertySymbol.Type); - ContainingType = propertySymbol.ContainingType.ToDisplayString(Helpers.TypeDisplayFormat); + ContainingType = propertySymbol.ContainingType.ToQualifiedDisplayString(Helpers.TypeDisplayFormat); DeclaredContainingType = ContainingType; IsIndexer = propertySymbol.IsIndexer; IsAbstract = propertySymbol.IsAbstract; diff --git a/Source/Mockolate.SourceGenerators/Entities/Type.cs b/Source/Mockolate.SourceGenerators/Entities/Type.cs index e32f5a7dd..d109dfdf0 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Type.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Type.cs @@ -14,7 +14,7 @@ private Type(string fullname) internal Type(ITypeSymbol typeSymbol) { // Removes '*' from multi-dimensional array types - Fullname = typeSymbol.ToDisplayString(Helpers.TypeDisplayFormat).Replace("*", ""); + Fullname = typeSymbol.ToQualifiedDisplayString(Helpers.TypeDisplayFormat).Replace("*", ""); DisplayName = typeSymbol.ToDisplayString(Helpers.TypeDisplayShortFormat).Replace("*", ""); Namespace = typeSymbol.ContainingNamespace?.ToString(); if (typeSymbol is INamedTypeSymbol namedTypeSymbol) diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 02a2bf13e..2d0eaf174 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Mockolate.SourceGenerators.Entities; +using Mockolate.SourceGenerators.Internals; using Attribute = Mockolate.SourceGenerators.Entities.Attribute; using Type = Mockolate.SourceGenerators.Entities.Type; @@ -26,6 +27,36 @@ internal static class Helpers typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters); + /// + /// Formats like , but qualifies types from a + /// reference with only extern aliases by that alias instead of global::, under which they do not resolve. + /// + public static string ToQualifiedDisplayString(this ISymbol symbol, SymbolDisplayFormat format) + { + EntityCache? cache = EntityCache.Current; + if (cache is null || !cache.HasExternAliases) + { + return symbol.ToDisplayString(format); + } + + ImmutableArray parts = symbol.ToDisplayParts(format); + StringBuilder sb = new(); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Symbol is INamespaceSymbol { IsGlobalNamespace: true, } globalNamespace && + cache.GetExternAlias(globalNamespace.ContainingAssembly) is { } alias) + { + sb.Append(alias); + } + else + { + sb.Append(parts[i]); + } + } + + return sb.ToString(); + } + public static string EscapeIfKeyword(string name) => SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name; diff --git a/Source/Mockolate.SourceGenerators/Internals/EntityCache.cs b/Source/Mockolate.SourceGenerators/Internals/EntityCache.cs index 9ce1424c5..6052afcec 100644 --- a/Source/Mockolate.SourceGenerators/Internals/EntityCache.cs +++ b/Source/Mockolate.SourceGenerators/Internals/EntityCache.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using Mockolate.SourceGenerators.Entities; @@ -30,10 +31,17 @@ internal sealed class EntityCache // `IsNullableAnnotated` flags and produce different generated code. private readonly ConcurrentDictionary _types = new(SymbolEqualityComparer.IncludeNullability); + private readonly Dictionary _externAliases; + + private EntityCache(Compilation compilation) + { + _externAliases = GetExternAliases(compilation); + } + [field: ThreadStatic] public static EntityCache? Current { get; private set; } public static EntityCache GetOrCreate(Compilation compilation) - => _caches.GetValue(compilation, static _ => new EntityCache()); + => _caches.GetValue(compilation, static c => new EntityCache(c)); public static Scope EnterScope(EntityCache cache) { @@ -48,6 +56,44 @@ public Type GetOrAddType(ITypeSymbol symbol, Func factory) public MethodParameter GetOrAddParameter(IParameterSymbol symbol, Func factory) => _parameters.GetOrAdd(symbol, factory); + public bool HasExternAliases => _externAliases.Count > 0; + + /// + /// Returns the extern alias through which types of must be qualified, or + /// when they are reachable through global::. + /// + public string? GetExternAlias(IAssemblySymbol assembly) + => _externAliases.TryGetValue(assembly, out string? alias) ? alias : null; + + private static Dictionary GetExternAliases(Compilation compilation) + { + Dictionary externAliases = new(SymbolEqualityComparer.Default); + foreach (MetadataReference reference in compilation.References) + { + ImmutableArray aliases = reference.Properties.Aliases; + if (!aliases.IsEmpty && !aliases.Contains("global") && + compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly) + { + externAliases[assembly] = aliases[0]; + } + } + + if (externAliases.Count > 0) + { + foreach (MetadataReference reference in compilation.References) + { + ImmutableArray aliases = reference.Properties.Aliases; + if ((aliases.IsEmpty || aliases.Contains("global")) && + compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly) + { + externAliases.Remove(assembly); + } + } + } + + return externAliases; + } + public readonly struct Scope : IDisposable { private static void ExitScope(EntityCache? previous) => Current = previous; diff --git a/Source/Mockolate.SourceGenerators/MockGenerator.cs b/Source/Mockolate.SourceGenerators/MockGenerator.cs index 9a22ffe88..b6a22937c 100644 --- a/Source/Mockolate.SourceGenerators/MockGenerator.cs +++ b/Source/Mockolate.SourceGenerators/MockGenerator.cs @@ -51,6 +51,9 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext .Select(static (compilation, _) => HasAttribute(compilation, "System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute")); + IncrementalValueProvider> externAliases = context.CompilationProvider + .Select(static (compilation, _) => GetExternAliases(compilation)); + // Naming step: cross-mock disambiguation. Cached as a unit; one NamedMock per emission. IncrementalValueProvider> namedMocksAggregate = collectedMocks .Select(static (arr, _) => CreateNamedMocks(arr)); @@ -60,19 +63,19 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext // Per-mock source output: cache hit when the NamedMock is identity-equal to last run. context.RegisterSourceOutput( - perMock.Combine(hasOverloadResolutionPriority), - static (spc, source) => EmitMockFile(spc, source.Left, source.Right)); + perMock.Combine(hasOverloadResolutionPriority).Combine(externAliases), + static (spc, source) => EmitMockFile(spc, source.Left.Left, source.Left.Right, source.Right)); // As bridge extensions across (a, b) pairs. Cross-mock dedup; one file. IncrementalValueProvider> asPairs = namedMocksAggregate .Select(static (arr, _) => CollectAsExtensionPairs(arr)); - context.RegisterSourceOutput(asPairs, static (spc, pairs) => + context.RegisterSourceOutput(asPairs.Combine(externAliases), static (spc, source) => { - if (pairs.Count > 0) + if (source.Left.Count > 0) { spc.AddSource("Mock.AsExtensions.g.cs", - ToSource(Sources.Sources.MockAsExtensions(pairs))); + ToSource(Sources.Sources.MockAsExtensions(source.Left), source.Right)); } }); @@ -165,8 +168,33 @@ static bool HasAttribute(Compilation c, string attributeName) } } - private static SourceText ToSource(string source) - => SourceText.From(Sources.Sources.ExpandCrefs(source), Encoding.UTF8); + private static SourceText ToSource(string source, EquatableArray externAliases = default) + => SourceText.From(Sources.Sources.ExpandCrefs(WithExternAliases(source, externAliases)), Encoding.UTF8); + + private static EquatableArray GetExternAliases(Compilation compilation) + => new(compilation.References + .SelectMany(reference => reference.Properties.Aliases) + .Where(alias => alias != "global") + .Distinct() + .OrderBy(alias => alias, StringComparer.Ordinal) + .ToArray()); + + private static string WithExternAliases(string source, EquatableArray externAliases) + { + StringBuilder? directives = null; + foreach (string alias in externAliases) + { + if (source.IndexOf(alias + "::", StringComparison.Ordinal) >= 0) + { + directives ??= new StringBuilder(); + directives.AppendLine().Append("extern alias ").Append(alias).Append(';'); + } + } + + return directives is null + ? source + : source.Insert(Sources.Sources.AutoGeneratedHeader.Length, directives.ToString()); + } private static EquatableArray Distinct(ImmutableArray mocks) { @@ -492,7 +520,8 @@ static void AddIfNew(HashSet set, List } } - private static void EmitMockFile(SourceProductionContext context, NamedMock named, bool hasOverloadResolutionPriority) + private static void EmitMockFile(SourceProductionContext context, NamedMock named, bool hasOverloadResolutionPriority, + EquatableArray externAliases) { string fileName = named.FileName; Class @class = named.Mock; @@ -500,7 +529,7 @@ private static void EmitMockFile(SourceProductionContext context, NamedMock name if (@class is MockClass { Delegate: not null, } mockClass) { context.AddSource($"Mock.{fileName}.g.cs", - ToSource(Sources.Sources.MockDelegate(named.ParentName, mockClass, mockClass.Delegate))); + ToSource(Sources.Sources.MockDelegate(named.ParentName, mockClass, mockClass.Delegate), externAliases)); return; } @@ -518,7 +547,7 @@ private static void EmitMockFile(SourceProductionContext context, NamedMock name } context.AddSource($"Mock.{fileName}.g.cs", - ToSource(Sources.Sources.MockClass(named.ParentName, @class, hasOverloadResolutionPriority, hiddenBaseArr))); + ToSource(Sources.Sources.MockClass(named.ParentName, @class, hasOverloadResolutionPriority, hiddenBaseArr), externAliases)); return; } @@ -530,7 +559,7 @@ private static void EmitMockFile(SourceProductionContext context, NamedMock name } context.AddSource($"Mock.{fileName}.g.cs", - ToSource(Sources.Sources.MockCombinationClass(fileName, named.ParentName, @class, additionalArr))); + ToSource(Sources.Sources.MockCombinationClass(fileName, named.ParentName, @class, additionalArr), externAliases)); } private static bool IsValidMockDeclaration(MockClass mockClass) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.cs index 1d9fabb32..70976e4b5 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.cs @@ -7,19 +7,21 @@ namespace Mockolate.SourceGenerators.Sources; internal static partial class Sources { + internal const string AutoGeneratedHeader = """ + //---------------------- + // + // This code was generated by the Mockolate source generator. + // + // Changes to this file may cause incorrect behavior and + // will be lost if the code is regenerated! + // + //---------------------- + """; + private static StringBuilder InitializeBuilder() { StringBuilder sb = new(); - sb.AppendLine(""" - //---------------------- - // - // This code was generated by the Mockolate source generator. - // - // Changes to this file may cause incorrect behavior and - // will be lost if the code is regenerated! - // - //---------------------- - """); + sb.AppendLine(AutoGeneratedHeader); sb.AppendLine(); return sb; diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index 2f06b29aa..abc4a7fe1 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.CodeAnalysis; namespace Mockolate.SourceGenerators.Tests; @@ -641,6 +642,130 @@ await That(result.Sources["Mock.Derived.g.cs"]) "the reachable getter could be restated in the mock class, but the setup and verify surfaces would have to name `Configuration`, which is only reachable through inheritance (CS0122)"); } + [Test] + public async Task ExternAliasedReference_ShouldQualifyItsTypesWithTheAlias() + { + MetadataReference external = CompileExternAliasAssembly().WithAliases(["ExtAlias",]); + + GeneratorResult result = Generator.RunWithReferences(""" + extern alias ExtAlias; + using Mockolate; + + namespace MyCode; + + public interface ILocal + { + ExtAlias::Ext.Payload Echo(ExtAlias::Ext.Payload payload); + } + + public class Program + { + public static void Main(string[] args) + { + _ = ExtAlias::Ext.IMyService.CreateMock(); + _ = ExtAlias::Ext.MyAbstractService.CreateMock(); + _ = ExtAlias::Ext.MyCallback.CreateMock(); + _ = ILocal.CreateMock().Implementing(); + } + } + """, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.IMyService.g.cs").And + .ContainsKey("Mock.MyAbstractService.g.cs").And + .ContainsKey("Mock.MyCallback.g.cs").And + .ContainsKey("Mock.ILocal.g.cs").And + .ContainsKey("Mock.AsExtensions.g.cs"); + await That(result.Sources["Mock.AsExtensions.g.cs"]).Contains("extern alias ExtAlias;"); + await That(result.Sources["Mock.IMyService.g.cs"]) + .Contains(""" + // + //---------------------- + extern alias ExtAlias; + + #nullable enable annotations + """).IgnoringNewlineStyle() + .Because("extern alias directives must precede all other code, but the auto-generated header must stay first").And + .Contains("ExtAlias::Ext.Payload").And + .Contains("(ExtAlias::Ext.Color)1"); + await That(result.Sources["Mock.MyAbstractService.g.cs"]) + .Contains( + "[ExtAlias::Ext.Marker(typeof(ExtAlias::Ext.Payload), typeof(global::System.Collections.Generic.Dictionary<,>), (ExtAlias::Ext.Color)1, new ExtAlias::Ext.Color[]{0})]"); + await That(string.Concat(result.Sources.Values)).DoesNotContain("global::Ext.") + .Because("types of a reference without the global alias do not resolve under `global::` (CS0400)"); + foreach (string source in result.Sources.Values.Where(source => source.Contains("ExtAlias::"))) + { + await That(source).Contains("extern alias ExtAlias;") + .Because("an extern alias is only usable in a file that declares it"); + } + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task ExternAliasedReference_AlsoReachableThroughGlobal_ShouldKeepGlobalQualification( + bool asSeparateReference) + { + MetadataReference external = CompileExternAliasAssembly(); + MetadataReference[] references = asSeparateReference + ? [external.WithAliases(["ExtAlias",]), external,] + : [external.WithAliases(["global", "ExtAlias",]),]; + + GeneratorResult result = Generator.RunWithReferences(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) => _ = Ext.IMyService.CreateMock(); + } + """, references); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.IMyService.g.cs"]) + .Contains("global::Ext.Payload").And + .DoesNotContain("ExtAlias"); + } + + private static MetadataReference CompileExternAliasAssembly() + => ExternalAssembly.Compile(""" + using System; + using System.Threading.Tasks; + + namespace Ext; + + public enum Color { Red, Green, } + + public class Payload { } + + public class MarkerAttribute(Type type, Type openType, Color color, Color[] colors) : Attribute { } + + public class PayloadEventArgs : EventArgs { } + + public delegate Payload MyCallback(Payload payload); + + public interface IMyService + { + Payload Value { get; set; } + Payload this[Color color] { get; set; } + event EventHandler Changed; + Payload Get(Color color = Color.Green); + Task GetAllAsync(); + } + + public interface IOther + { + void Take(Payload payload); + } + + public abstract class MyAbstractService + { + [Marker(typeof(Payload), typeof(System.Collections.Generic.Dictionary<,>), Color.Green, new[] { Color.Red, })] + public abstract Payload Compute(Payload payload); + } + """); + private static MetadataReference CompileClientBaseAssembly(bool grantsInternalsVisibleTo) { string internalsVisibleTo = grantsInternalsVisibleTo From 22ade355a112f71f2bd3442c1635a4b41f039402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 31 Oct 2026 10:33:13 +0100 Subject: [PATCH 2/2] fix: harden extern alias support in the generator An unresolved namespace-qualified type in a mocked member made the generator throw whenever an extern alias was present, dropping every mock. Attribute array elements ignored their kind, so non-zero enums and types inside arrays did not compile. Every output now receives the aliases explicitly so no file can miss the directive. --- .../Entities/AttributeParameter.cs | 30 +++++++------- Source/Mockolate.SourceGenerators/Helpers.cs | 4 +- .../MockGenerator.cs | 28 +++++++------ .../GeneralTests.cs | 40 +++++++++++++++++++ .../MockTests.CrossAssemblyTests.cs | 38 ++++++++++++++++-- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs b/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs index e61958079..1cbb0019d 100644 --- a/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs +++ b/Source/Mockolate.SourceGenerators/Entities/AttributeParameter.cs @@ -7,26 +7,24 @@ internal record AttributeParameter { public AttributeParameter(TypedConstant value) { - if (value.Kind == TypedConstantKind.Array) - { - Value = $"new {value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}{{{string.Join(", ", value.Values.Select(SerializeConstantValue))}}}"; - } - else if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol type) - { - Value = $"typeof({type.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)})"; - } - else if (value.Kind == TypedConstantKind.Enum) - { - Value = $"({value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}){value.Value}"; - } - else - { - Value = SerializeConstantValue(value); - } + Value = Serialize(value); } public string? Value { get; } + private static string Serialize(TypedConstant value) + => value.Kind switch + { + TypedConstantKind.Array when value.IsNull => "null", + TypedConstantKind.Array => + $"new {value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}{{{string.Join(", ", value.Values.Select(Serialize))}}}", + TypedConstantKind.Type when value.Value is ITypeSymbol type => + $"typeof({type.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)})", + TypedConstantKind.Enum => + $"({value.Type?.ToQualifiedDisplayString(Helpers.TypeDisplayFormat)}){value.Value}", + _ => SerializeConstantValue(value), + }; + private static string SerializeConstantValue(TypedConstant value) { if (value.Value is null) diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 2d0eaf174..1bc44d303 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -43,8 +43,8 @@ public static string ToQualifiedDisplayString(this ISymbol symbol, SymbolDisplay StringBuilder sb = new(); for (int i = 0; i < parts.Length; i++) { - if (parts[i].Symbol is INamespaceSymbol { IsGlobalNamespace: true, } globalNamespace && - cache.GetExternAlias(globalNamespace.ContainingAssembly) is { } alias) + if (parts[i].Symbol is INamespaceSymbol { IsGlobalNamespace: true, ContainingAssembly: { } assembly, } && + cache.GetExternAlias(assembly) is { } alias) { sb.Append(alias); } diff --git a/Source/Mockolate.SourceGenerators/MockGenerator.cs b/Source/Mockolate.SourceGenerators/MockGenerator.cs index b6a22937c..b96c9fac6 100644 --- a/Source/Mockolate.SourceGenerators/MockGenerator.cs +++ b/Source/Mockolate.SourceGenerators/MockGenerator.cs @@ -18,7 +18,7 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext { context.RegisterPostInitializationOutput(ctx => ctx.AddSource( "Mock.g.cs", - ToSource(Sources.Sources.MockClass()))); + ToSource(Sources.Sources.MockClass(), default))); // Per-mock stream. Equality on MockClass is now keyed only on its canonical identity // (ClassFullName + AdditionalImplementations.ClassFullName), so Roslyn can correctly skip @@ -84,30 +84,31 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext .Select(static (arr, _) => CollectIndexerSetupKeys(arr)); context.RegisterSourceOutput( - indexerSetupKeys.Combine(hasOverloadResolutionPriority), + indexerSetupKeys.Combine(hasOverloadResolutionPriority).Combine(externAliases), static (spc, source) => { - if (source.Left.Count == 0) + if (source.Left.Left.Count == 0) { return; } spc.AddSource("IndexerSetups.g.cs", - ToSource(Sources.Sources.IndexerSetups(source.Left, source.Right))); + ToSource(Sources.Sources.IndexerSetups(source.Left.Left, source.Left.Right), source.Right)); }); IncrementalValueProvider> methodSetupKeys = collectedMocks .Select(static (arr, _) => CollectMethodSetupKeys(arr)); - context.RegisterSourceOutput(methodSetupKeys, static (spc, keys) => + context.RegisterSourceOutput(methodSetupKeys.Combine(externAliases), static (spc, source) => { + (EquatableArray keys, EquatableArray aliases) = source; if (keys.Count == 0) { return; } HashSet<(int, bool)> set = ToMethodSetupHashSet(keys); - spc.AddSource("MethodSetups.g.cs", ToSource(Sources.Sources.MethodSetups(set))); + spc.AddSource("MethodSetups.g.cs", ToSource(Sources.Sources.MethodSetups(set), aliases)); const int dotNetFuncActionParameterLimit = 16; if (set.Any(x => x.Item1 >= dotNetFuncActionParameterLimit)) @@ -117,22 +118,23 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext .Where(x => x.Item1 >= dotNetFuncActionParameterLimit) .SelectMany<(int, bool), int>(x => [x.Item1, x.Item1 + 1,]) .Where(x => x > dotNetFuncActionParameterLimit) - .Distinct()))); + .Distinct()), aliases)); } if (set.Any(x => !x.Item2)) { spc.AddSource("ReturnsThrowsAsyncExtensions.g.cs", ToSource(Sources.Sources.ReturnsThrowsAsyncExtensions(set - .Where(x => !x.Item2).Select(x => x.Item1).ToArray()))); + .Where(x => !x.Item2).Select(x => x.Item1).ToArray()), aliases)); } }); IncrementalValueProvider refStructAggregate = collectedMocks .Select(static (arr, _) => CollectRefStructAggregate(arr)); - context.RegisterSourceOutput(refStructAggregate, static (spc, agg) => + context.RegisterSourceOutput(refStructAggregate.Combine(externAliases), static (spc, source) => { + (RefStructAggregate agg, EquatableArray aliases) = source; if (agg.MethodSetups.Count == 0 && agg.IndexerSetups.Count == 0) { return; @@ -146,7 +148,7 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext } spc.AddSource("RefStructMethodSetups.g.cs", - ToSource(Sources.Sources.RefStructMethodSetups(methodSet, indexerArities))); + ToSource(Sources.Sources.RefStructMethodSetups(methodSet, indexerArities), aliases)); }); // MockBehaviorExtensions: only depends on whether HttpClient appears as a mock target. @@ -154,9 +156,9 @@ void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext IncrementalValueProvider includeHttpClient = collectedMocks .Select(static (arr, _) => arr.AsArray().Any(m => m.ClassFullName == "global::System.Net.Http.HttpClient")); - context.RegisterSourceOutput(includeHttpClient, static (spc, hasHttp) => + context.RegisterSourceOutput(includeHttpClient.Combine(externAliases), static (spc, source) => spc.AddSource("MockBehaviorExtensions.g.cs", - ToSource(Sources.Sources.MockBehaviorExtensions(hasHttp)))); + ToSource(Sources.Sources.MockBehaviorExtensions(source.Left), source.Right))); static bool HasAttribute(Compilation c, string attributeName) { @@ -168,7 +170,7 @@ static bool HasAttribute(Compilation c, string attributeName) } } - private static SourceText ToSource(string source, EquatableArray externAliases = default) + private static SourceText ToSource(string source, EquatableArray externAliases) => SourceText.From(Sources.Sources.ExpandCrefs(WithExternAliases(source, externAliases)), Encoding.UTF8); private static EquatableArray GetExternAliases(Compilation compilation) diff --git a/Tests/Mockolate.SourceGenerators.Tests/GeneralTests.cs b/Tests/Mockolate.SourceGenerators.Tests/GeneralTests.cs index b8271f434..b75b9aa3a 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/GeneralTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/GeneralTests.cs @@ -776,6 +776,46 @@ await That(result.Sources["Mock.IMyInterface.g.cs"]) .IgnoringNewlineStyle(); } + [Test] + public async Task WithArrayAttributeArguments_ShouldSerializeEachElementByItsKind() + { + GeneratorResult result = Generator + .Run(""" + using System; + using Mockolate; + + namespace MyCode; + public class Program + { + public static void Main(string[] args) + { + _ = IMyService.CreateMock(); + } + } + + public interface IMyService + { + [Marker(new[] { Color.Green, }, new[] { typeof(string), typeof(Payload), }, new object[] { Color.Green, typeof(int), 5, null, }, null)] + void MyMethod(); + } + + public enum Color { Red, Green, } + + public class Payload { } + + public class MarkerAttribute : Attribute + { + public MarkerAttribute(Color[] colors, Type[] types, object[] values, Color[] none) { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.IMyService.g.cs"]) + .Contains( + "[global::MyCode.Marker(new global::MyCode.Color[]{(global::MyCode.Color)1}, new global::System.Type[]{typeof(string), typeof(global::MyCode.Payload)}, new object[]{(global::MyCode.Color)1, typeof(int), 5, null}, null)]") + .Because("array elements need the same enum casts and typeof expressions as single arguments, e.g. `{1}` does not convert to an enum (CS0266)"); + } + [Test] public async Task WithAttributes_ShouldAddAttributesToGeneratedCode() { diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index abc4a7fe1..9f06ba9f2 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -690,7 +690,7 @@ await That(result.Sources["Mock.IMyService.g.cs"]) .Contains("(ExtAlias::Ext.Color)1"); await That(result.Sources["Mock.MyAbstractService.g.cs"]) .Contains( - "[ExtAlias::Ext.Marker(typeof(ExtAlias::Ext.Payload), typeof(global::System.Collections.Generic.Dictionary<,>), (ExtAlias::Ext.Color)1, new ExtAlias::Ext.Color[]{0})]"); + "[ExtAlias::Ext.Marker(typeof(ExtAlias::Ext.Payload), typeof(global::System.Collections.Generic.Dictionary<,>), (ExtAlias::Ext.Color)1, new ExtAlias::Ext.Color[]{(ExtAlias::Ext.Color)1}, new global::System.Type[]{typeof(ExtAlias::Ext.Payload)})]"); await That(string.Concat(result.Sources.Values)).DoesNotContain("global::Ext.") .Because("types of a reference without the global alias do not resolve under `global::` (CS0400)"); foreach (string source in result.Sources.Values.Where(source => source.Contains("ExtAlias::"))) @@ -728,6 +728,38 @@ await That(result.Sources["Mock.IMyService.g.cs"]) .DoesNotContain("ExtAlias"); } + [Test] + public async Task ExternAliasedReference_WithUnresolvedTypeInMockedMember_ShouldStillGenerateMocks() + { + MetadataReference external = CompileExternAliasAssembly().WithAliases(["ExtAlias",]); + + GeneratorResult result = Generator.RunWithReferences(""" + extern alias ExtAlias; + using Mockolate; + + namespace MyCode; + + public interface ILocal + { + System.Missing Foo(ExtAlias::Ext.Payload payload); + } + + public class Program + { + public static void Main(string[] args) + { + _ = ILocal.CreateMock(); + _ = ExtAlias::Ext.IOther.CreateMock(); + } + } + """, [external,]); + + await That(string.Join(Environment.NewLine, result.Diagnostics)).DoesNotContain("CS8785") + .Because("an error type qualified by a namespace has no containing assembly, which must not make the generator throw"); + await That(result.Sources).ContainsKey("Mock.IOther.g.cs") + .Because("an unresolved type in one mocked member must not suppress the other mocks"); + } + private static MetadataReference CompileExternAliasAssembly() => ExternalAssembly.Compile(""" using System; @@ -739,7 +771,7 @@ public enum Color { Red, Green, } public class Payload { } - public class MarkerAttribute(Type type, Type openType, Color color, Color[] colors) : Attribute { } + public class MarkerAttribute(Type type, Type openType, Color color, Color[] colors, Type[] types) : Attribute { } public class PayloadEventArgs : EventArgs { } @@ -761,7 +793,7 @@ public interface IOther public abstract class MyAbstractService { - [Marker(typeof(Payload), typeof(System.Collections.Generic.Dictionary<,>), Color.Green, new[] { Color.Red, })] + [Marker(typeof(Payload), typeof(System.Collections.Generic.Dictionary<,>), Color.Green, new[] { Color.Green, }, new[] { typeof(Payload), })] public abstract Payload Compute(Payload payload); } """);