Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 4 additions & 3 deletions TUnit.Mocks.SourceGenerator/Builders/MockBridgeBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq;
using TUnit.Mocks.SourceGenerator.Models;
using static TUnit.Mocks.SourceGenerator.IdentifierEscaping;

namespace TUnit.Mocks.SourceGenerator.Builders;

Expand Down Expand Up @@ -99,7 +100,7 @@ private static void GenerateStaticMethodDim(CodeWriter writer, MockMemberModel m

writer.AppendLineIfNotEmpty(method.ObsoleteAttribute);

using (writer.Block($"static {signatureReturnType} {method.ExplicitInterfaceName}.{method.Name}{typeParams}({paramList}){constraints}"))
using (writer.Block($"static {signatureReturnType} {method.ExplicitInterfaceName}.{EscapeIdentifier(method.Name)}{typeParams}({paramList}){constraints}"))
{
GenerateStaticEngineDispatchBody(writer, method, staticEngineTypeName);
}
Expand All @@ -108,7 +109,7 @@ private static void GenerateStaticMethodDim(CodeWriter writer, MockMemberModel m
private static void GenerateStaticPropertyDim(CodeWriter writer, MockMemberModel prop, string staticEngineTypeName)
{
writer.AppendLineIfNotEmpty(prop.ObsoleteAttribute);
writer.AppendLine($"static {prop.ReturnType} {prop.ExplicitInterfaceName}.{prop.Name}");
writer.AppendLine($"static {prop.ReturnType} {prop.ExplicitInterfaceName}.{EscapeIdentifier(prop.Name)}");
writer.OpenBrace();

if (prop.HasGetter)
Expand Down Expand Up @@ -146,7 +147,7 @@ private static void GenerateStaticPropertyDim(CodeWriter writer, MockMemberModel
private static void GenerateStaticEventDim(CodeWriter writer, MockEventModel evt)
{
writer.AppendLineIfNotEmpty(evt.ObsoleteAttribute);
writer.AppendLine($"static event {evt.EventHandlerType} {evt.ExplicitInterfaceName}.{evt.Name}");
writer.AppendLine($"static event {evt.EventHandlerType} {evt.ExplicitInterfaceName}.{EscapeIdentifier(evt.Name)}");
writer.OpenBrace();
writer.AppendLine("add { }");
writer.AppendLine("remove { }");
Expand Down
3 changes: 2 additions & 1 deletion TUnit.Mocks.SourceGenerator/Builders/MockEventsBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TUnit.Mocks.SourceGenerator.Models;
using static TUnit.Mocks.SourceGenerator.IdentifierEscaping;

namespace TUnit.Mocks.SourceGenerator.Builders;

Expand Down Expand Up @@ -49,7 +50,7 @@ public static string Build(MockTypeModel model)
if (!first) writer.AppendLine();
first = false;

writer.AppendLine($"public global::TUnit.Mocks.EventSubscriptionAccessor {evt.Name}");
writer.AppendLine($"public global::TUnit.Mocks.EventSubscriptionAccessor {EscapeIdentifier(evt.Name)}");
writer.AppendLine($" => new(events.Engine, \"{evt.Name}\");");
}
}
Expand Down
63 changes: 32 additions & 31 deletions TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Immutable;
using System.Linq;
using TUnit.Mocks.SourceGenerator.Models;
using static TUnit.Mocks.SourceGenerator.IdentifierEscaping;

namespace TUnit.Mocks.SourceGenerator.Builders;

Expand Down Expand Up @@ -137,7 +138,7 @@ private static string GetWrapperName(string safeName, MockMemberModel method)
=> $"{safeName}_{method.Name}_M{method.MemberId}_MockCall";

private static string GetSafeMemberName(string name)
=> MockMemberNames.Contains(name) ? name + "_" : name;
=> EscapeIdentifier(MockMemberNames.Contains(name) ? name + "_" : name);

private static string GetCombinedTypeParameterList(MockTypeModel model, MockMemberModel method)
{
Expand Down
15 changes: 8 additions & 7 deletions TUnit.Mocks.SourceGenerator/Builders/MockWrapperTypeBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq;
using TUnit.Mocks.SourceGenerator.Models;
using static TUnit.Mocks.SourceGenerator.IdentifierEscaping;

namespace TUnit.Mocks.SourceGenerator.Builders;

Expand Down Expand Up @@ -99,14 +100,14 @@ private static void GenerateMethodForwarding(CodeWriter writer, MockMemberModel

if (method.IsVoid && !method.IsAsync)
{
using (writer.Block($"{returnType} {interfaceName}.{method.Name}{typeParams}({paramList}){constraints}"))
using (writer.Block($"{returnType} {interfaceName}.{EscapeIdentifier(method.Name)}{typeParams}({paramList}){constraints}"))
{
writer.AppendLine($"{target}.{method.Name}{typeParams}({argPassList});");
writer.AppendLine($"{target}.{EscapeIdentifier(method.Name)}{typeParams}({argPassList});");
}
}
else
{
writer.AppendLine($"{returnType} {interfaceName}.{method.Name}{typeParams}({paramList}){constraints} => {target}.{method.Name}{typeParams}({argPassList});");
writer.AppendLine($"{returnType} {interfaceName}.{EscapeIdentifier(method.Name)}{typeParams}({paramList}){constraints} => {target}.{EscapeIdentifier(method.Name)}{typeParams}({argPassList});");
}
}

Expand All @@ -126,18 +127,18 @@ private static void GeneratePropertyForwarding(CodeWriter writer, MockMemberMode
// Per-accessor [Obsolete] is injected inline so the property line stays a one-liner.
var getterAttr = prop.GetterObsoleteAttribute.Length > 0 ? prop.GetterObsoleteAttribute + " " : "";
var setterAttr = prop.SetterObsoleteAttribute.Length > 0 ? prop.SetterObsoleteAttribute + " " : "";
var getter = prop.HasGetter ? $"{getterAttr}get => {target}.{prop.Name}; " : "";
var setter = prop.HasSetter ? $"{setterAttr}set => {target}.{prop.Name} = value; " : "";
var getter = prop.HasGetter ? $"{getterAttr}get => {target}.{EscapeIdentifier(prop.Name)}; " : "";
var setter = prop.HasSetter ? $"{setterAttr}set => {target}.{EscapeIdentifier(prop.Name)} = value; " : "";

writer.AppendLine($"{returnType} {interfaceName}.{prop.Name} {{ {getter}{setter}}}");
writer.AppendLine($"{returnType} {interfaceName}.{EscapeIdentifier(prop.Name)} {{ {getter}{setter}}}");
}

private static void GenerateEventForwarding(CodeWriter writer, MockEventModel evt, MockTypeModel model)
{
var interfaceName = evt.ExplicitInterfaceName ?? evt.DeclaringInterfaceName ?? model.FullyQualifiedName;

writer.AppendLineIfNotEmpty(evt.ObsoleteAttribute);
writer.AppendLine($"event {evt.EventHandlerType} {interfaceName}.{evt.Name} {{ add => Object.{evt.Name} += value; remove => Object.{evt.Name} -= value; }}");
writer.AppendLine($"event {evt.EventHandlerType} {interfaceName}.{EscapeIdentifier(evt.Name)} {{ add => Object.{EscapeIdentifier(evt.Name)} += value; remove => Object.{EscapeIdentifier(evt.Name)} -= value; }}");
}

}
9 changes: 1 addition & 8 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using TUnit.Mocks.SourceGenerator.Extensions;
using TUnit.Mocks.SourceGenerator.Models;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using static TUnit.Mocks.SourceGenerator.IdentifierEscaping;

namespace TUnit.Mocks.SourceGenerator.Discovery;

Expand Down Expand Up @@ -1040,13 +1040,6 @@ private static string EscapeStringLiteral(string value)
.Replace("\t", "\\t")
+ "\"";

/// <summary>
/// Escapes a parameter name that is a C# reserved keyword by prepending '@'.
/// E.g., "event" → "@event", "class" → "@class", "return" → "@return".
/// </summary>
private static string EscapeIdentifier(string name) =>
SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name;

/// <summary>
/// For ReadOnlySpan&lt;T&gt; or Span&lt;T&gt; types, returns the fully qualified element type.
/// Returns null for all other types.
Expand Down
24 changes: 24 additions & 0 deletions TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.CodeAnalysis.CSharp;

namespace TUnit.Mocks.SourceGenerator;

/// <summary>
/// Utility for escaping C# identifiers that collide with reserved keywords.
/// Used by all builders at member-name emission sites so that types declaring
/// members like <c>@class</c>, <c>@event</c>, <c>@record</c> compile.
/// <para>
/// IMPORTANT: Stored model <c>Name</c> values must remain UNESCAPED — they are
/// used for engine dispatch keys, logging, and identity. Only escape at the
/// point where the name becomes a C# identifier in the generated source.
/// </para>
/// </summary>
internal static class IdentifierEscaping
{
/// <summary>
/// Returns <paramref name="name"/> prefixed with <c>@</c> when it is a C# reserved keyword,
/// otherwise returns <paramref name="name"/> unchanged.
/// E.g., <c>"event"</c> → <c>"@event"</c>, <c>"class"</c> → <c>"@class"</c>, <c>"Foo"</c> → <c>"Foo"</c>.
/// </summary>
public static string EscapeIdentifier(string name) =>
SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name;
}
35 changes: 29 additions & 6 deletions TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,15 @@ public interface ICancellableStream
// on its constructor and skip initializing required members. Separate
// generator fix.

// ─── T18 SKIPPED. Member names matching C# keywords (`class`, `event`, `record`)
// are passed through to the generator as unescaped identifiers, producing
// malformed emission (CS0539, CS0106, CS0066 on the generated impl). The
// EscapeIdentifier helper exists but is only applied to parameter names.
// Separate generator fix to apply it to method/property/event names.
// ─── T18. Member names matching C# keywords (`class`, `event`, `record`) ────

public interface IEscapedNames
{
int @class { get; }
string @record();
void @event(int @params);
int @namespace(int @new, int @static);
}

// ─── T19. Obsolete member ───────────────────────────────────────────────────

Expand Down Expand Up @@ -464,7 +468,26 @@ static async IAsyncEnumerable<int> Yield(params int[] values)

// T17 test elided — see the SKIPPED note above the type declarations.

// T18 test elided — see the SKIPPED note above the type declarations.
// ── T18 ──

[Test]
public async Task T18_Member_Names_That_Are_Contextual_Keywords()
{
var mock = IEscapedNames.Mock();
mock.@class.Returns(7);
mock.@record().Returns("rec");
mock.@namespace(Any<int>(), Any<int>()).Returns(123);

await Assert.That(mock.Object.@class).IsEqualTo(7);
await Assert.That(mock.Object.@record()).IsEqualTo("rec");
await Assert.That(mock.Object.@namespace(1, 2)).IsEqualTo(123);
mock.Object.@event(99);

mock.@class.WasCalled(Times.Once);
mock.@record().WasCalled(Times.Once);
mock.@event(Any<int>()).WasCalled(Times.Once);
mock.@namespace(Any<int>(), Any<int>()).WasCalled(Times.Once);
}

// ── T19 ──

Expand Down
Loading