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
8 changes: 6 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ updates:
target-branch: "develop"
schedule:
interval: "weekly"
day: "monday"
day: "friday"
time: "17:00"
timezone: "America/New_York"
open-pull-requests-limit: 10
assignees:
- "philcarbone"
Expand All @@ -24,7 +26,9 @@ updates:
target-branch: "develop"
schedule:
interval: "weekly"
day: "monday"
day: "friday"
time: "17:00"
timezone: "America/New_York"
open-pull-requests-limit: 5
assignees:
- "philcarbone"
Expand Down
11 changes: 10 additions & 1 deletion src/Whizbang.Generators/JsonMessageTypeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ internal sealed record JsonMessageTypeInfo(
bool IsSerializable,
PropertyInfo[] Properties,
bool HasParameterizedConstructor
);
) {
/// <summary>
/// Unique identifier derived from fully qualified name, suitable for C# identifiers.
/// Strips "global::" prefix and replaces "." with "_".
/// E.g., "global::MyApp.Commands.StartCommand" becomes "MyApp_Commands_StartCommand".
/// This prevents duplicate field/method names when types have the same SimpleName.
/// </summary>
/// <tests>tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs:Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync</tests>
public string UniqueIdentifier => FullyQualifiedName.Replace("global::", "").Replace(".", "_");
}

/// <summary>
/// Information about a property for JSON serialization.
Expand Down
11 changes: 10 additions & 1 deletion src/Whizbang.Generators/ListTypeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,13 @@ public sealed record ListTypeInfo(
string ListTypeName,
string ElementTypeName,
string ElementSimpleName
);
) {
/// <summary>
/// Unique identifier derived from element type name, suitable for C# identifiers.
/// Strips "global::" prefix and replaces "." with "_".
/// E.g., "global::MyApp.Models.OrderLineItem" becomes "MyApp_Models_OrderLineItem".
/// This prevents duplicate field/method names when element types have the same SimpleName.
/// </summary>
/// <tests>tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs:Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync</tests>
public string ElementUniqueIdentifier => ElementTypeName.Replace("global::", "").Replace(".", "_");
}
22 changes: 10 additions & 12 deletions src/Whizbang.Generators/MessageJsonContextGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class MessageJsonContextGenerator : IIncrementalGenerator {
private const string PLACEHOLDER_MESSAGE_ID = "MessageId";
private const string PLACEHOLDER_FULLY_QUALIFIED_NAME = "__FULLY_QUALIFIED_NAME__";
private const string PLACEHOLDER_SIMPLE_NAME = "__SIMPLE_NAME__";
private const string PLACEHOLDER_SAFE_NAME = "__SAFE_NAME__";
private const string PLACEHOLDER_UNIQUE_IDENTIFIER = "__UNIQUE_IDENTIFIER__";
private const string PLACEHOLDER_GLOBAL = "global::";
private const string PLACEHOLDER_INDEX = "__INDEX__";
private const string PLACEHOLDER_PROPERTY_TYPE = "__PROPERTY_TYPE__";
Expand Down Expand Up @@ -395,7 +395,7 @@ private static string _generateLazyFields(Assembly assembly, ImmutableArray<Json
foreach (var type in allTypes) {
var field = messageFieldSnippet
.Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName)
.Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName));
.Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier);
sb.AppendLine(field);
}
sb.AppendLine();
Expand All @@ -404,7 +404,7 @@ private static string _generateLazyFields(Assembly assembly, ImmutableArray<Json
foreach (var type in allTypes.Where(t => t.IsCommand || t.IsEvent)) {
var field = envelopeFieldSnippet
.Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName)
.Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName));
.Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier);
sb.AppendLine(field);
}

Expand Down Expand Up @@ -466,7 +466,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray<Jso
foreach (var type in allTypes) {
var check = messageCheckSnippet
.Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName)
.Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName));
.Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier);
sb.AppendLine(check);
sb.AppendLine();
}
Expand All @@ -476,7 +476,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray<Jso
foreach (var type in allTypes.Where(t => t.IsCommand || t.IsEvent)) {
var check = envelopeCheckSnippet
.Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName)
.Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName));
.Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier);
sb.AppendLine(check);
sb.AppendLine();
}
Expand All @@ -487,7 +487,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray<Jso
foreach (var listType in listTypes) {
var check = listCheckSnippet
.Replace("__ELEMENT_TYPE__", listType.ElementTypeName)
.Replace("__ELEMENT_SAFE_NAME__", _toSafeMethodName(listType.ElementTypeName));
.Replace("__ELEMENT_UNIQUE_IDENTIFIER__", listType.ElementUniqueIdentifier);
sb.AppendLine(check);
sb.AppendLine();
}
Expand Down Expand Up @@ -612,8 +612,7 @@ private static string _generateMessageTypeFactories(Assembly assembly, Immutable
"PARAMETER_INFO_VALUES");

foreach (var message in messages) {
var safeName = _toSafeMethodName(message.FullyQualifiedName);
sb.AppendLine($"private JsonTypeInfo<{message.FullyQualifiedName}> Create_{safeName}(JsonSerializerOptions options) {{");
sb.AppendLine($"private JsonTypeInfo<{message.FullyQualifiedName}> Create_{message.UniqueIdentifier}(JsonSerializerOptions options) {{");

// Generate properties array
sb.AppendLine($" var properties = new JsonPropertyInfo[{message.Properties.Length}];");
Expand Down Expand Up @@ -724,8 +723,7 @@ private static string _generateMessageEnvelopeFactories(Assembly assembly, Immut
"PARAMETER_INFO_VALUES");

foreach (var message in messages) {
var safeName = _toSafeMethodName(message.FullyQualifiedName);
sb.AppendLine($"private JsonTypeInfo<MessageEnvelope<{message.FullyQualifiedName}>> CreateMessageEnvelope_{safeName}(JsonSerializerOptions options) {{");
sb.AppendLine($"private JsonTypeInfo<MessageEnvelope<{message.FullyQualifiedName}>> CreateMessageEnvelope_{message.UniqueIdentifier}(JsonSerializerOptions options) {{");

// Generate properties array for MessageEnvelope<T> (MessageId, Payload, Hops)
sb.AppendLine(" var properties = new JsonPropertyInfo[3];");
Expand Down Expand Up @@ -1037,7 +1035,7 @@ private static string _generateListLazyFields(Assembly assembly, ImmutableArray<
foreach (var listType in listTypes) {
var field = snippet
.Replace("__ELEMENT_TYPE__", listType.ElementTypeName)
.Replace("__ELEMENT_SAFE_NAME__", _toSafeMethodName(listType.ElementTypeName));
.Replace("__ELEMENT_UNIQUE_IDENTIFIER__", listType.ElementUniqueIdentifier);
sb.AppendLine(field);
}

Expand All @@ -1063,7 +1061,7 @@ private static string _generateListFactories(Assembly assembly, ImmutableArray<L
foreach (var listType in listTypes) {
var factory = snippet
.Replace("__ELEMENT_TYPE__", listType.ElementTypeName)
.Replace("__ELEMENT_SAFE_NAME__", _toSafeMethodName(listType.ElementTypeName));
.Replace("__ELEMENT_UNIQUE_IDENTIFIER__", listType.ElementUniqueIdentifier);
sb.AppendLine(factory);
sb.AppendLine();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ internal class JsonContextSnippets {
#endregion

#region LAZY_FIELD_MESSAGE
private JsonTypeInfo<__FULLY_QUALIFIED_NAME__>? ___SAFE_NAME__;
private JsonTypeInfo<__FULLY_QUALIFIED_NAME__>? ___UNIQUE_IDENTIFIER__;
#endregion

#region LAZY_FIELD_MESSAGE_ENVELOPE
private JsonTypeInfo<MessageEnvelope<__FULLY_QUALIFIED_NAME__>>? _MessageEnvelope___SAFE_NAME__;
private JsonTypeInfo<MessageEnvelope<__FULLY_QUALIFIED_NAME__>>? _MessageEnvelope___UNIQUE_IDENTIFIER__;
#endregion

#region GET_TYPE_INFO_VALUE_OBJECT
Expand All @@ -32,13 +32,13 @@ internal class JsonContextSnippets {

#region GET_TYPE_INFO_MESSAGE
if (type == typeof(__FULLY_QUALIFIED_NAME__)) {
return Create___SAFE_NAME__(options);
return Create___UNIQUE_IDENTIFIER__(options);
}
#endregion

#region GET_TYPE_INFO_MESSAGE_ENVELOPE
if (type == typeof(MessageEnvelope<__FULLY_QUALIFIED_NAME__>)) {
return CreateMessageEnvelope___SAFE_NAME__(options);
return CreateMessageEnvelope___UNIQUE_IDENTIFIER__(options);
}
#endregion

Expand All @@ -52,17 +52,17 @@ internal class JsonContextSnippets {
#endregion

#region LAZY_FIELD_LIST
private JsonTypeInfo<global::System.Collections.Generic.List<__ELEMENT_TYPE__>>? _List___ELEMENT_SAFE_NAME__;
private JsonTypeInfo<global::System.Collections.Generic.List<__ELEMENT_TYPE__>>? _List___ELEMENT_UNIQUE_IDENTIFIER__;
#endregion

#region GET_TYPE_INFO_LIST
if (type == typeof(global::System.Collections.Generic.List<__ELEMENT_TYPE__>)) {
return CreateList___ELEMENT_SAFE_NAME__(options);
return CreateList___ELEMENT_UNIQUE_IDENTIFIER__(options);
}
#endregion

#region LIST_TYPE_FACTORY
private JsonTypeInfo<global::System.Collections.Generic.List<__ELEMENT_TYPE__>> CreateList___ELEMENT_SAFE_NAME__(JsonSerializerOptions options) {
private JsonTypeInfo<global::System.Collections.Generic.List<__ELEMENT_TYPE__>> CreateList___ELEMENT_UNIQUE_IDENTIFIER__(JsonSerializerOptions options) {
var elementInfo = GetOrCreateTypeInfo<__ELEMENT_TYPE__>(options);
var collectionInfo = new JsonCollectionInfoValues<global::System.Collections.Generic.List<__ELEMENT_TYPE__>> {
ObjectCreator = static () => new global::System.Collections.Generic.List<__ELEMENT_TYPE__>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public record CreateOrder(string OrderId) : ICommand;
await Assert.That(code).IsNotNull();

// Should generate specific factory method for MessageEnvelope<CreateOrder>
// Safe names use fully qualified path to avoid collisions (e.g., MyApp_Commands_CreateOrder)
// Uses unique identifier from fully qualified name: MyApp.Commands.CreateOrder -> MyApp_Commands_CreateOrder
await Assert.That(code!).Contains("CreateMessageEnvelope_MyApp_Commands_CreateOrder");
await Assert.That(code).Contains("MessageEnvelope<global::MyApp.Commands.CreateOrder>");
}
Expand Down Expand Up @@ -613,7 +613,51 @@ public record CreateOrder(string OrderId, List<PublicDetail> PublicItems) : ICom
await Assert.That(code).IsNotNull();
await Assert.That(code!).Contains("CreateOrder");
await Assert.That(code).Contains("PublicDetail");
// PublicDetail should have factory method with safe name (namespace-qualified)
// PublicDetail should have factory method (uses unique identifier from FQN)
await Assert.That(code).Contains("Create_MyApp_PublicDetail");
}

[Test]
[RequiresAssemblyFiles()]
public async Task Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync() {
// Arrange - Two types with same SimpleName but different namespaces
// This would previously cause duplicate field names (_StartCommand) and factory methods
var source = """
using Whizbang.Core;

namespace MyApp.Commands {
public record StartCommand(string Data) : ICommand;
}

namespace MyApp.Events {
public record StartCommand(string Data) : IEvent;
}
""";

// Act
var result = GeneratorTestHelper.RunGenerator<MessageJsonContextGenerator>(source);

// Assert - Should not have compilation errors (duplicate identifiers would cause errors)
await Assert.That(result.Diagnostics).DoesNotContain(d => d.Severity == DiagnosticSeverity.Error);

var code = GeneratorTestHelper.GetGeneratedSource(result, "MessageJsonContext.g.cs");
await Assert.That(code).IsNotNull();

// Both types should be present with fully qualified names
await Assert.That(code!).Contains("global::MyApp.Commands.StartCommand");
await Assert.That(code).Contains("global::MyApp.Events.StartCommand");

// Should have unique field names (not duplicate _StartCommand)
// Uses namespace-qualified identifiers like _MyApp_Commands_StartCommand
await Assert.That(code).Contains("_MyApp_Commands_StartCommand");
await Assert.That(code).Contains("_MyApp_Events_StartCommand");

// Should have unique factory method names
await Assert.That(code).Contains("Create_MyApp_Commands_StartCommand");
await Assert.That(code).Contains("Create_MyApp_Events_StartCommand");

// Should have unique MessageEnvelope factory method names
await Assert.That(code).Contains("CreateMessageEnvelope_MyApp_Commands_StartCommand");
await Assert.That(code).Contains("CreateMessageEnvelope_MyApp_Events_StartCommand");
}
}
Loading