- Add IsCollectionTypeByInterface() to detect types implementing IList<T>, ICollection<T>, IEnumerable<T> - Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces - Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc. - Update GenerateComplexTypeMessage to generate `repeated T items` for collection types - Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction This fixes the invalid proto syntax where C# indexers (this[]) were being generated as proto fields, causing proto compilation errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
340 lines
13 KiB
C#
340 lines
13 KiB
C#
using System;
|
|
using Microsoft.CodeAnalysis;
|
|
|
|
namespace Svrnty.CQRS.Grpc.Generators;
|
|
|
|
/// <summary>
|
|
/// Maps C# types to Protocol Buffer types for proto file generation
|
|
/// </summary>
|
|
internal static class ProtoFileTypeMapper
|
|
{
|
|
public static string MapType(ITypeSymbol typeSymbol, out bool needsImport, out string? importPath)
|
|
{
|
|
needsImport = false;
|
|
importPath = null;
|
|
|
|
// Handle special name (fully qualified name)
|
|
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
|
var typeName = typeSymbol.Name;
|
|
|
|
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
|
|
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
|
|
|
|
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
|
|
if (typeSymbol is INamedTypeSymbol nullableType &&
|
|
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
|
|
nullableType.TypeArguments.Length == 1)
|
|
{
|
|
// Unwrap the nullable and map the inner type
|
|
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
|
|
}
|
|
|
|
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
|
|
if (typeSymbol is INamedTypeSymbol collectionType)
|
|
{
|
|
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
|
|
var collectionTypeName = collectionType.Name;
|
|
if (collectionType.TypeArguments.Length == 1 &&
|
|
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
|
|
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
|
|
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
|
|
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
|
|
{
|
|
var elementType = collectionType.TypeArguments[0];
|
|
var protoElementType = MapType(elementType, out needsImport, out importPath);
|
|
return $"repeated {protoElementType}";
|
|
}
|
|
|
|
// Dictionary<K, V>
|
|
if (collectionType.TypeArguments.Length == 2 &&
|
|
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
|
|
{
|
|
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
|
|
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
|
|
|
|
// Set import flags if either key or value needs imports
|
|
if (keyNeedsImport)
|
|
{
|
|
needsImport = true;
|
|
importPath = keyImportPath;
|
|
}
|
|
if (valueNeedsImport)
|
|
{
|
|
needsImport = true;
|
|
importPath = valueImportPath; // Note: This only captures last import, may need improvement
|
|
}
|
|
|
|
return $"map<{keyType}, {valueType}>";
|
|
}
|
|
}
|
|
|
|
// Basic types
|
|
switch (typeName)
|
|
{
|
|
case "String":
|
|
return "string";
|
|
case "Int32":
|
|
return "int32";
|
|
case "UInt32":
|
|
return "uint32";
|
|
case "Int64":
|
|
return "int64";
|
|
case "UInt64":
|
|
return "uint64";
|
|
case "Int16":
|
|
return "int32"; // Proto has no int16
|
|
case "UInt16":
|
|
return "uint32"; // Proto has no uint16
|
|
case "Byte":
|
|
return "uint32"; // Proto has no byte
|
|
case "SByte":
|
|
return "int32"; // Proto has no sbyte
|
|
case "Boolean":
|
|
return "bool";
|
|
case "Single":
|
|
return "float";
|
|
case "Double":
|
|
return "double";
|
|
case "Byte[]":
|
|
return "bytes";
|
|
case "Guid":
|
|
// Guid serialized as string
|
|
return "string";
|
|
case "Decimal":
|
|
// Decimal serialized as string (no native decimal in proto)
|
|
return "string";
|
|
case "DateTime":
|
|
case "DateTimeOffset":
|
|
needsImport = true;
|
|
importPath = "google/protobuf/timestamp.proto";
|
|
return "google.protobuf.Timestamp";
|
|
case "TimeSpan":
|
|
needsImport = true;
|
|
importPath = "google/protobuf/duration.proto";
|
|
return "google.protobuf.Duration";
|
|
case "JsonElement":
|
|
needsImport = true;
|
|
importPath = "google/protobuf/struct.proto";
|
|
return "google.protobuf.Struct";
|
|
}
|
|
|
|
// Enums
|
|
if (typeSymbol.TypeKind == TypeKind.Enum)
|
|
{
|
|
return typeName; // Use the enum name directly
|
|
}
|
|
|
|
// Complex types (classes/records) become message types
|
|
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
|
|
{
|
|
return typeName; // Reference the message type by name
|
|
}
|
|
|
|
// Fallback
|
|
return "string"; // Default to string for unknown types
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts C# PascalCase property name to proto snake_case field name
|
|
/// </summary>
|
|
public static string ToSnakeCase(string pascalCase)
|
|
{
|
|
if (string.IsNullOrEmpty(pascalCase))
|
|
return pascalCase;
|
|
|
|
var result = new System.Text.StringBuilder();
|
|
result.Append(char.ToLowerInvariant(pascalCase[0]));
|
|
|
|
for (int i = 1; i < pascalCase.Length; i++)
|
|
{
|
|
var c = pascalCase[i];
|
|
if (char.IsUpper(c))
|
|
{
|
|
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key")
|
|
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1]))
|
|
{
|
|
result.Append(char.ToLowerInvariant(c));
|
|
}
|
|
else
|
|
{
|
|
result.Append('_');
|
|
result.Append(char.ToLowerInvariant(c));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
result.Append(c);
|
|
}
|
|
}
|
|
|
|
return result.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a type should be skipped/ignored for proto generation
|
|
/// </summary>
|
|
public static bool IsUnsupportedType(ITypeSymbol typeSymbol)
|
|
{
|
|
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
|
|
|
// Skip these types - they should trigger a warning/error
|
|
if (fullTypeName.Contains("System.IO.Stream") ||
|
|
fullTypeName.Contains("System.Threading.CancellationToken") ||
|
|
fullTypeName.Contains("System.Threading.Tasks.Task") ||
|
|
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
|
|
fullTypeName.Contains("System.Func") ||
|
|
fullTypeName.Contains("System.Action") ||
|
|
fullTypeName.Contains("System.Delegate"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the element type from a collection type, or returns the type itself if not a collection.
|
|
/// Also unwraps Nullable types.
|
|
/// </summary>
|
|
public static ITypeSymbol GetElementOrUnderlyingType(ITypeSymbol typeSymbol)
|
|
{
|
|
// Unwrap Nullable<T>
|
|
if (typeSymbol is INamedTypeSymbol nullableType &&
|
|
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
|
|
nullableType.TypeArguments.Length == 1)
|
|
{
|
|
return GetElementOrUnderlyingType(nullableType.TypeArguments[0]);
|
|
}
|
|
|
|
// Extract element type from collections
|
|
if (typeSymbol is INamedTypeSymbol collectionType && collectionType.TypeArguments.Length == 1)
|
|
{
|
|
var typeName = collectionType.Name;
|
|
if (typeName.Contains("List") || typeName.Contains("Collection") ||
|
|
typeName.Contains("Enumerable") || typeName.Contains("Array") ||
|
|
typeName.Contains("Set") || typeName.Contains("IList") ||
|
|
typeName.Contains("ICollection") || typeName.Contains("IEnumerable"))
|
|
{
|
|
return GetElementOrUnderlyingType(collectionType.TypeArguments[0]);
|
|
}
|
|
}
|
|
|
|
return typeSymbol;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the type is an enum (including nullable enums)
|
|
/// </summary>
|
|
public static bool IsEnumType(ITypeSymbol typeSymbol)
|
|
{
|
|
var underlying = GetElementOrUnderlyingType(typeSymbol);
|
|
return underlying.TypeKind == TypeKind.Enum;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the enum type symbol if this is an enum or nullable enum, otherwise null
|
|
/// </summary>
|
|
public static INamedTypeSymbol? GetEnumType(ITypeSymbol typeSymbol)
|
|
{
|
|
var underlying = GetElementOrUnderlyingType(typeSymbol);
|
|
if (underlying.TypeKind == TypeKind.Enum && underlying is INamedTypeSymbol enumType)
|
|
{
|
|
return enumType;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
|
|
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
|
|
/// </summary>
|
|
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
|
|
{
|
|
if (typeSymbol is not INamedTypeSymbol namedType)
|
|
return false;
|
|
|
|
// Skip string (implements IEnumerable<char>)
|
|
if (namedType.SpecialType == SpecialType.System_String)
|
|
return false;
|
|
|
|
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
|
|
foreach (var iface in namedType.AllInterfaces)
|
|
{
|
|
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
|
|
{
|
|
var ifaceName = iface.OriginalDefinition.ToDisplayString();
|
|
if (ifaceName == "System.Collections.Generic.IList<T>" ||
|
|
ifaceName == "System.Collections.Generic.ICollection<T>" ||
|
|
ifaceName == "System.Collections.Generic.IEnumerable<T>" ||
|
|
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
|
|
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
|
|
/// Returns null if the type is not a collection
|
|
/// </summary>
|
|
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
|
|
{
|
|
if (typeSymbol is not INamedTypeSymbol namedType)
|
|
return null;
|
|
|
|
// Skip string
|
|
if (namedType.SpecialType == SpecialType.System_String)
|
|
return null;
|
|
|
|
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
|
|
ITypeSymbol? elementType = null;
|
|
int priority = 0;
|
|
|
|
foreach (var iface in namedType.AllInterfaces)
|
|
{
|
|
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
|
|
{
|
|
var ifaceName = iface.OriginalDefinition.ToDisplayString();
|
|
int currentPriority = 0;
|
|
|
|
if (ifaceName == "System.Collections.Generic.IList<T>" ||
|
|
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
|
|
currentPriority = 3;
|
|
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
|
|
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
|
|
currentPriority = 2;
|
|
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
|
|
currentPriority = 1;
|
|
|
|
if (currentPriority > priority)
|
|
{
|
|
priority = currentPriority;
|
|
elementType = iface.TypeArguments[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
return elementType;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collection-internal properties that should be skipped when generating proto messages
|
|
/// </summary>
|
|
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
|
|
{
|
|
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Checks if a property name is a collection-internal property that should be skipped
|
|
/// </summary>
|
|
public static bool IsCollectionInternalProperty(string propertyName)
|
|
{
|
|
return CollectionInternalProperties.Contains(propertyName);
|
|
}
|
|
}
|