Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/TUnit.Mocks.SourceGenerator/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
/// <summary>
/// Diagnostics reported by the generator itself. Everything the analyzer can see at a call site
/// belongs in TUnit.Mocks.Analyzers (TM001-TM007); this file is for failures only the generator
/// can observe, which is currently just whole-compilation name collisions.
/// can observe, such as whole-compilation name collisions and unexpected generation failures.
/// </summary>
internal static class Diagnostics
{
public static readonly DiagnosticDescriptor TM008_GeneratedNameCollision = new(
id: "TM008",

Check warning on line 13 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Check warning on line 13 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Check warning on line 13 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

title: "Mocked types produce the same generated name",
messageFormat: "Cannot mock '{0}' because it produces the same generated name '{1}' as '{2}'. Rename one of the types or namespaces.",
category: "TUnit.Mocks",
Expand All @@ -18,4 +18,14 @@
isEnabledByDefault: true,
description: "Generated type and file names are derived from the mocked type's fully qualified name with separators replaced by underscores. Two types can still map to the same name when their namespaces differ only in how underscores and dots are arranged (e.g. 'A_.B.IFoo' and 'A._B.IFoo'). Emitting both would give Roslyn duplicate hint names, which discards every mock in the compilation without saying why, so generation is skipped for the colliding types and reported here instead."
);

public static readonly DiagnosticDescriptor TM009_GenerationFailed = new(
id: "TM009",

Check warning on line 23 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Check warning on line 23 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Check warning on line 23 in src/TUnit.Mocks.SourceGenerator/Diagnostics.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

title: "Mock generation failed",
messageFormat: "Failed to generate mock for '{0}': {1}: {2}",
category: "TUnit.Mocks",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "An unexpected exception prevented TUnit.Mocks from generating a requested mock. The exception type and message identify the failing generator path."
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,53 +23,56 @@ namespace TUnit.Mocks.SourceGenerator.Discovery;
internal static class GeneratedNameCollisionDetector
{
/// <summary>
/// Returns <paramref name="models"/> in input order, with <see cref="MockTypeModel.CollidesWith"/>
/// set on every model that shares its generated name with another.
/// Returns <paramref name="requests"/> in input order, with
/// <see cref="MockTypeModel.CollidesWith"/> set on every model that shares its generated name
/// with another. Each model remains paired with its original request location.
/// </summary>
internal static List<MockTypeModel> Annotate(IEnumerable<MockTypeModel> models)
internal static List<MockGenerationRequest> Annotate(IEnumerable<MockGenerationRequest> requests)
{
var ordered = models.ToList();
var ordered = requests.ToList();

// The name alone is not the key: a multi-interface combo and the secondary setup surface
// for the same (primary, interface) pair intentionally share a composite name and are told
// apart by the hint-name suffix, so they must not be flagged.
var groups = new Dictionary<(bool IsSecondaryMemberSurface, string Name), List<MockTypeModel>>();
var groups = new Dictionary<(bool IsSecondaryMemberSurface, string Name), List<MockGenerationRequest>>();

foreach (var model in ordered)
foreach (var request in ordered)
{
var model = request.Model;
var key = (model.IsSecondaryMemberSurface, MockImplBuilder.GetCompositeSafeName(model));

if (!groups.TryGetValue(key, out var group))
{
groups[key] = group = new List<MockTypeModel>();
groups[key] = group = new List<MockGenerationRequest>();
}

group.Add(model);
group.Add(request);
}

if (groups.Count == ordered.Count)
{
return ordered;
}

var annotated = new List<MockTypeModel>(ordered.Count);
var annotated = new List<MockGenerationRequest>(ordered.Count);

foreach (var model in ordered)
foreach (var request in ordered)
{
var model = request.Model;
var group = groups[(model.IsSecondaryMemberSurface, MockImplBuilder.GetCompositeSafeName(model))];

// Same target mocked in more than one mode (Mock.Of and Mock.Wrap of one type, say)
// reaches this point as separate models sharing an identity. Only distinct targets
// meeting at one name are a #6505 collision.
var others = group
.Where(other => Identity(other) != Identity(model))
.Select(other => other.FullyQualifiedName)
.Where(other => Identity(other.Model) != Identity(model))
.Select(other => other.Model.FullyQualifiedName)
.Distinct()
.ToList();

annotated.Add(others.Count == 0
? model
: model with { CollidesWith = string.Join(", ", others) });
? request
: request with { Model = model with { CollidesWith = string.Join(", ", others) } });
}

return annotated;
Expand Down
19 changes: 15 additions & 4 deletions src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -624,9 +624,9 @@ public static ImmutableArray<MockTypeModel> TransformMockExtensionInvocation(

/// <summary>
/// Semantic transform for <c>[assembly: GenerateMock(typeof(T))]</c>.
/// Extracts the type argument and builds a <see cref="MockTypeModel"/>.
/// Extracts the type argument and pairs each model with its attribute location.
/// </summary>
public static ImmutableArray<MockTypeModel> TransformGenerateMockAttribute(
public static ImmutableArray<MockGenerationRequest> TransformGenerateMockAttribute(
GeneratorAttributeSyntaxContext context, CancellationToken ct)
{
// The target symbol for an assembly attribute is the assembly itself
Expand All @@ -653,13 +653,24 @@ public static ImmutableArray<MockTypeModel> TransformGenerateMockAttribute(
if (namedType.IsValueType)
continue;

return BuildModelWithTransitiveDependencies(
var models = BuildModelWithTransitiveDependencies(
NormalizeSingleMockType(namedType),
isPartialMock: namedType.TypeKind == TypeKind.Class,
compilationAssembly,
compilation);

var location = attr.ApplicationSyntaxReference?.GetSyntax(ct).GetLocation()
?? context.TargetNode.GetLocation();
var sourceLocation = MockSourceLocation.From(location);
var requests = ImmutableArray.CreateBuilder<MockGenerationRequest>(models.Length);
foreach (var model in models)
{
requests.Add(new MockGenerationRequest(model, sourceLocation));
}

return requests.MoveToImmutable();
}

return ImmutableArray<MockTypeModel>.Empty;
return ImmutableArray<MockGenerationRequest>.Empty;
}
}
184 changes: 128 additions & 56 deletions src/TUnit.Mocks.SourceGenerator/MockGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using TUnit.Mocks.SourceGenerator.Builders;
using TUnit.Mocks.SourceGenerator.Discovery;
Expand All @@ -8,6 +9,17 @@ namespace TUnit.Mocks.SourceGenerator;
[Generator(LanguageNames.CSharp)]
public class MockGenerator : IIncrementalGenerator
{
private readonly Action<SourceProductionContext, MockTypeModel> _emitSources;

public MockGenerator() : this(EmitSources)
{
}

internal MockGenerator(Action<SourceProductionContext, MockTypeModel> emitSources)
{
_emitSources = emitSources;
}

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Always emit the TUnit.Mocks.Generated namespace so that global usings never fail
Expand All @@ -26,23 +38,27 @@ namespace TUnit.Mocks.Generated;
var mockTypes = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: MockTypeDiscovery.IsMockOfInvocation,
transform: MockTypeDiscovery.TransformToModels)
.SelectMany((models, _) => models);
transform: static (ctx, ct) => CreateRequests(
MockTypeDiscovery.TransformToModels(ctx, ct),
ctx.Node.GetLocation()))
.SelectMany((requests, _) => requests);

// Step 1b: Find all [assembly: GenerateMock(typeof(T))] attributes
var attributeTypes = context.SyntaxProvider
.ForAttributeWithMetadataName(
"TUnit.Mocks.GenerateMockAttribute",
predicate: static (node, _) => true,
transform: MockTypeDiscovery.TransformGenerateMockAttribute)
.SelectMany((models, _) => models);
.SelectMany((requests, _) => requests);

// Step 1c: Find all IFoo.Mock() static extension invocations
var extensionTypes = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: MockTypeDiscovery.IsMockExtensionInvocation,
transform: MockTypeDiscovery.TransformMockExtensionInvocation)
.SelectMany((models, _) => models);
transform: static (ctx, ct) => CreateRequests(
MockTypeDiscovery.TransformMockExtensionInvocation(ctx, ct),
ctx.Node.GetLocation()))
.SelectMany((requests, _) => requests);

// Step 2: Merge all sources and deduplicate
var distinctTypes = mockTypes
Expand All @@ -52,69 +68,125 @@ namespace TUnit.Mocks.Generated;
.SelectMany((pair, _) =>
{
var (mockOfAndAttribute, extensionInvocations) = pair;
var (mockOfTypes, attributeTypes) = mockOfAndAttribute;
var (mockOfRequests, attributeRequests) = mockOfAndAttribute;
var set = new HashSet<MockTypeModel>();
foreach (var m in mockOfTypes) set.Add(m);
foreach (var m in attributeTypes) set.Add(m);
foreach (var m in extensionInvocations) set.Add(m);
var requests = new List<MockGenerationRequest>();

AddDistinctRequests(mockOfRequests, set, requests);
AddDistinctRequests(attributeRequests, set, requests);
AddDistinctRequests(extensionInvocations, set, requests);

// Flag types that would emit the same generated names before anything is written:
// duplicate hint names abort the generator and take every mock in the compilation
// with them. See issue #6505.
return GeneratedNameCollisionDetector.Annotate(set);
return GeneratedNameCollisionDetector.Annotate(requests);
});

// Step 3: Generate source for each unique type
context.RegisterSourceOutput(distinctTypes, (spc, model) =>
context.RegisterSourceOutput(distinctTypes, GenerateMockSafely);
}

private void GenerateMockSafely(SourceProductionContext spc, MockGenerationRequest request)
{
try
{
if (model.CollidesWith is not null)
{
spc.ReportDiagnostic(Diagnostic.Create(
Diagnostics.TM008_GeneratedNameCollision,
Location.None,
model.FullyQualifiedName,
MockImplBuilder.GetCompositeSafeName(model),
model.CollidesWith));
return;
}
_emitSources(spc, request.Model);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
spc.ReportDiagnostic(Diagnostic.Create(
Diagnostics.TM009_GenerationFailed,
request.SourceLocation.ToLocation(),
request.Model.FullyQualifiedName,
exception.GetType().Name,
exception.Message));
}
}

if (model.IsSecondaryMemberSurface)
{
// Pair model: the shared setup/verify surface for one additional interface of a
// multi-type mock. Emitted once per (primary, interface) pair across all combos.
var secondaryMembersSource = MockMembersBuilder.Build(model);
spc.AddSource($"{GetSafeFileName(model)}_MockSecondaryMembers.g.cs", secondaryMembersSource);
}
else if (model.IsDelegateType)
{
// Delegate mock: generate members and delegate factory (no impl class)
GenerateDelegateMock(spc, model);
}
else if (model.LacksAccessibleConstructor)
{
// Unsubclassable class (every constructor private / cross-assembly internal).
// Emit only the static Mock() entry point so the call site still binds and the
// TM006 analyzer diagnostic is the single error the user sees, instead of a
// CS1729 pointing into generated code. See issue #6493.
GenerateUnconstructableClassStub(spc, model);
}
else if (model.IsWrapMock)
{
// Wrap mock: generate wrap impl, wrap factory, plus members
GenerateWrapMock(spc, model);
}
else if (model.AdditionalInterfaceNames.Length > 0)
{
// Multi-interface mock: generate impl + factory + secondary-member setup
// extensions. Primary members/raise come from the single-type model (also emitted).
GenerateMultiInterfaceMock(spc, model);
}
else
private static ImmutableArray<MockGenerationRequest> CreateRequests(
ImmutableArray<MockTypeModel> models,
Location location)
{
if (models.IsDefaultOrEmpty)
{
return ImmutableArray<MockGenerationRequest>.Empty;
}

var sourceLocation = MockSourceLocation.From(location);
var requests = ImmutableArray.CreateBuilder<MockGenerationRequest>(models.Length);
foreach (var model in models)
{
requests.Add(new MockGenerationRequest(model, sourceLocation));
}

return requests.MoveToImmutable();
}

private static void AddDistinctRequests(
ImmutableArray<MockGenerationRequest> requests,
HashSet<MockTypeModel> set,
List<MockGenerationRequest> distinctRequests)
{
foreach (var request in requests)
{
if (!set.Add(request.Model))
{
// Single-type mock: generate everything
GenerateSingleTypeMock(spc, model);
continue;
}
});

distinctRequests.Add(request);
}
}

internal static void EmitSources(SourceProductionContext spc, MockTypeModel model)
{
if (model.CollidesWith is not null)
{
spc.ReportDiagnostic(Diagnostic.Create(
Diagnostics.TM008_GeneratedNameCollision,
Location.None,
model.FullyQualifiedName,
MockImplBuilder.GetCompositeSafeName(model),
model.CollidesWith));
return;
}

if (model.IsSecondaryMemberSurface)
{
// Pair model: the shared setup/verify surface for one additional interface of a
// multi-type mock. Emitted once per (primary, interface) pair across all combos.
var secondaryMembersSource = MockMembersBuilder.Build(model);
spc.AddSource($"{GetSafeFileName(model)}_MockSecondaryMembers.g.cs", secondaryMembersSource);
}
else if (model.IsDelegateType)
{
// Delegate mock: generate members and delegate factory (no impl class)
GenerateDelegateMock(spc, model);
}
else if (model.LacksAccessibleConstructor)
{
// Unsubclassable class (every constructor private / cross-assembly internal).
// Emit only the static Mock() entry point so the call site still binds and the
// TM006 analyzer diagnostic is the single error the user sees, instead of a
// CS1729 pointing into generated code. See issue #6493.
GenerateUnconstructableClassStub(spc, model);
}
else if (model.IsWrapMock)
{
// Wrap mock: generate wrap impl, wrap factory, plus members
GenerateWrapMock(spc, model);
}
else if (model.AdditionalInterfaceNames.Length > 0)
{
// Multi-interface mock: generate impl + factory + secondary-member setup
// extensions. Primary members/raise come from the single-type model (also emitted).
GenerateMultiInterfaceMock(spc, model);
}
else
{
// Single-type mock: generate everything
GenerateSingleTypeMock(spc, model);
}
}

private static void GenerateSingleTypeMock(SourceProductionContext spc, MockTypeModel model)
Expand Down
Loading
Loading