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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>1.1.1</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/layeredcraft/optimized-enums</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@
OE0006 | OptimizedEnums.Usage | Error | DiagnosticDescriptors
OE0101 | OptimizedEnums.Usage | Warning | DiagnosticDescriptors
OE0102 | OptimizedEnums.Usage | Warning | DiagnosticDescriptors
OE0202 | OptimizedEnums.Usage | Warning | DiagnosticDescriptors
OE0203 | OptimizedEnums.Usage | Warning | DiagnosticDescriptors
OE0204 | OptimizedEnums.Usage | Warning | DiagnosticDescriptors
OE9001 | OptimizedEnums.Usage | Error | DiagnosticDescriptors
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ internal static class DiagnosticDescriptors
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

internal static readonly DiagnosticDescriptor IndexPropertyNotEquatable = new(
"OE0202",
"OptimizedEnumIndex property type does not implement IEquatable<T>",
"The property '{0}' of type '{1}' is marked [OptimizedEnumIndex] but its type does not implement IEquatable<T>; the index will not be generated. Implement IEquatable<T> on the property type or remove the attribute.",
UsageCategory,
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

internal static readonly DiagnosticDescriptor IndexPropertyNotAccessible = new(
"OE0203",
"OptimizedEnumIndex property must be an accessible instance property with a readable getter",
"The property '{0}' on '{1}' is marked [OptimizedEnumIndex] but cannot be used as an index because it must be a non-static, accessible instance property with a readable getter. The index will not be generated.",
UsageCategory,
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

internal static readonly DiagnosticDescriptor IndexPropertyNameConflict = new(
"OE0204",
"OptimizedEnumIndex property name conflicts with a reserved generated member",
"The property '{0}' is marked [OptimizedEnumIndex] but its name conflicts with a reserved generated member name ('{1}'). The index will not be generated. Rename the property or remove the attribute.",
UsageCategory,
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

internal static readonly DiagnosticDescriptor GeneratorInternalError = new(
"OE9001",
"OptimizedEnum generator internal error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ internal static void Generate(SourceProductionContext context, EnumInfo info)
info.FullyQualifiedClassName,
info.ValueTypeFullyQualified,
MemberNames = info.MemberNames.ToArray(),
IndexedProperties = info.IndexedProperties.ToArray(),
Preamble = BuildPreamble(info),
Suffix = BuildSuffix(info),
info.HasNotNullWhenAttribute,
};

// Use the fully-qualified name (minus "global::") as the hint name to avoid
Expand Down
17 changes: 12 additions & 5 deletions src/LayeredCraft.OptimizedEnums.Generator/Models/EnumInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ internal sealed record EnumInfo(
EquatableArray<string> MemberNames,
EquatableArray<string> ContainingTypeNames,
EquatableArray<DiagnosticInfo> Diagnostics,
LocationInfo? Location
EquatableArray<IndexedPropertyInfo> IndexedProperties,
LocationInfo? Location,
bool HasNotNullWhenAttribute
)
{
// Location is intentionally excluded from equality so that a position-only change
Expand All @@ -28,9 +30,14 @@ other is not null
&& ValueTypeFullyQualified == other.ValueTypeFullyQualified
&& MemberNames == other.MemberNames
&& ContainingTypeNames == other.ContainingTypeNames
&& Diagnostics == other.Diagnostics;
&& Diagnostics == other.Diagnostics
&& IndexedProperties == other.IndexedProperties
&& HasNotNullWhenAttribute == other.HasNotNullWhenAttribute;

public override int GetHashCode() =>
HashCode.Combine(Namespace, ClassName, FullyQualifiedClassName, ValueTypeFullyQualified,
MemberNames, ContainingTypeNames, Diagnostics);
public override int GetHashCode()
{
var h = HashCode.Combine(Namespace, ClassName, FullyQualifiedClassName, ValueTypeFullyQualified,
MemberNames, ContainingTypeNames, Diagnostics, IndexedProperties);
return HashCode.Combine(h, HasNotNullWhenAttribute);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace LayeredCraft.OptimizedEnums.Generator.Models;

/// <summary>
/// Represents a property decorated with <c>[OptimizedEnumIndex]</c> that the generator
/// should emit a pre-built dictionary lookup for on the concrete enum class.
/// </summary>
/// <param name="PropertyName">The property name as declared (e.g. "SlotValue").</param>
/// <param name="PropertyTypeFullyQualified">The fully-qualified type of the property.</param>
/// <param name="IsStringType">True when the property type is <see cref="string"/>.</param>
/// <param name="StringComparerExpression">
/// The <c>StringComparer</c> expression to pass to the dictionary constructor
/// (e.g. "global::System.StringComparer.Ordinal"). Empty for non-string types.
/// </param>
internal sealed record IndexedPropertyInfo(
string PropertyName,
string PropertyTypeFullyQualified,
bool IsStringType,
string StringComparerExpression
) : IEquatable<IndexedPropertyInfo>;
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) =>
var diagnostics = new List<DiagnosticInfo>();
var location = classDecl.CreateLocationInfo();
var className = classSymbol.Name;
var compilation = context.SemanticModel.Compilation;

var hasNotNullWhen = compilation.GetTypeByMetadataName(
"System.Diagnostics.CodeAnalysis.NotNullWhenAttribute") is not null;

// OE0001: Must be partial
var isPartial = classDecl.Modifiers.Any(static m => m.IsKind(SyntaxKind.PartialKeyword));
Expand All @@ -53,7 +57,9 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) =>
MemberNames: EquatableArray<string>.Empty,
ContainingTypeNames: EquatableArray<string>.Empty,
Diagnostics: diagnostics.ToEquatableArray(),
Location: location);
IndexedProperties: EquatableArray<IndexedPropertyInfo>.Empty,
Location: location,
HasNotNullWhenAttribute: hasNotNullWhen);
}

// Extract TValue (second type argument of OptimizedEnum<TEnum, TValue>)
Expand Down Expand Up @@ -142,6 +148,9 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) =>
className));
}

var indexedProperties = CollectIndexedProperties(
classSymbol, context.SemanticModel.Compilation, diagnostics);

return new EnumInfo(
Namespace: GetNamespace(classSymbol),
ClassName: className,
Expand All @@ -150,7 +159,9 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) =>
MemberNames: validMembers.ToEquatableArray(),
ContainingTypeNames: GetContainingTypeDeclarations(classSymbol),
Diagnostics: diagnostics.ToEquatableArray(),
Location: location);
IndexedProperties: indexedProperties,
Location: location,
HasNotNullWhenAttribute: hasNotNullWhen);
}

private static INamedTypeSymbol? FindOptimizedEnumBase(
Expand Down Expand Up @@ -239,6 +250,140 @@ ObjectCreationExpressionSyntax or
}
}

private static EquatableArray<IndexedPropertyInfo> CollectIndexedProperties(
INamedTypeSymbol classSymbol,
Compilation compilation,
List<DiagnosticInfo> diagnostics)
{
const string attrMetadataName = "LayeredCraft.OptimizedEnums.OptimizedEnumIndexAttribute";
const string iEquatableMetadataName = "System.IEquatable`1";

var attrSymbol = compilation.GetTypeByMetadataName(attrMetadataName);
if (attrSymbol is null)
return EquatableArray<IndexedPropertyInfo>.Empty;

var iEquatableSymbol = compilation.GetTypeByMetadataName(iEquatableMetadataName);
var optimizedEnumBase = compilation.GetTypeByMetadataName(OptimizedEnumBaseMetadataName);

// Property names that would collide with existing generated members.
// "Name" → s_byName / FromName / TryFromName
// "Value" → s_byValue / FromValue / TryFromValue
var reservedNames = new HashSet<string>(StringComparer.Ordinal) { "Name", "Value" };

var result = new List<IndexedPropertyInfo>();
var seenNames = new HashSet<string>(StringComparer.Ordinal);

// Walk base chain (skip the concrete class itself), stop at OptimizedEnum<,>
var current = classSymbol.BaseType;
while (current is not null)
{
if (optimizedEnumBase is not null &&
SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, optimizedEnumBase))
break;

foreach (var member in current.GetMembers().OfType<IPropertySymbol>())
{
var attr = member.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attrSymbol));

if (attr is null)
continue;

// OE0203: property must be a non-static, accessible instance property with a readable getter
if (member.IsStatic ||
member.IsIndexer ||
member.Parameters.Length > 0 ||
member.GetMethod is null ||
member.DeclaredAccessibility == Accessibility.Private ||
member.GetMethod.DeclaredAccessibility == Accessibility.Private)
{
Comment on lines +295 to +299
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OE0203 validation only checks for private accessibility. If the indexed property is internal, private protected (ProtectedAndInternal), or otherwise inaccessible from the concrete enum’s assembly/type (e.g., when the intermediate base class comes from a referenced assembly), the generator can still emit s_by{PropertyName} initialization accessing that property, which will fail to compile. Consider validating accessibility using Roslyn’s accessibility APIs (e.g., compilation.IsSymbolAccessibleWithin(...) for the property/getter, potentially with throughType = concrete enum type) rather than only rejecting private.

Copilot uses AI. Check for mistakes.
diagnostics.Add(new DiagnosticInfo(
DiagnosticDescriptors.IndexPropertyNotAccessible,
member.CreateLocationInfo(),
member.Name,
current.Name));
continue;
}

// OE0204: property name must not conflict with reserved generated member names
if (reservedNames.Contains(member.Name))
{
var conflicting = member.Name == "Name"
? "s_byName, FromName, TryFromName"
: "s_byValue, FromValue, TryFromValue";
diagnostics.Add(new DiagnosticInfo(
DiagnosticDescriptors.IndexPropertyNameConflict,
member.CreateLocationInfo(),
member.Name,
conflicting));
continue;
}

var propType = member.Type;

// OE0202: warn if property type doesn't implement IEquatable<T>
if (iEquatableSymbol is not null)
{
var implementsIEquatable = propType.AllInterfaces.Any(i =>
SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, iEquatableSymbol) &&
i.TypeArguments.Length == 1 &&
SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], propType));

if (!implementsIEquatable)
{
diagnostics.Add(new DiagnosticInfo(
DiagnosticDescriptors.IndexPropertyNotEquatable,
member.CreateLocationInfo(),
member.Name,
propType.ToDisplayString()));
continue;
}
}

// Only mark name as seen after it has passed all validation checks, so that an
// invalid nearer-base property does not shadow a valid one higher in the chain.
if (!seenNames.Add(member.Name))
continue;

var isString = propType.SpecialType == SpecialType.System_String;
var comparerExpr = string.Empty;

if (isString)
{
var comparisonValue = 4; // StringComparison.Ordinal default
foreach (var namedArg in attr.NamedArguments)
{
if (namedArg.Key == "StringComparison" && namedArg.Value.Value is int cv)
{
comparisonValue = cv;
break;
}
}

comparerExpr = comparisonValue switch
{
0 => "global::System.StringComparer.CurrentCulture",
1 => "global::System.StringComparer.CurrentCultureIgnoreCase",
2 => "global::System.StringComparer.InvariantCulture",
3 => "global::System.StringComparer.InvariantCultureIgnoreCase",
5 => "global::System.StringComparer.OrdinalIgnoreCase",
_ => "global::System.StringComparer.Ordinal"
};
}

result.Add(new IndexedPropertyInfo(
PropertyName: member.Name,
PropertyTypeFullyQualified: propType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
IsStringType: isString,
StringComparerExpression: comparerExpr));
}

current = current.BaseType;
}

return result.ToEquatableArray();
}

private static EquatableArray<string> GetContainingTypeDeclarations(INamedTypeSymbol symbol)
{
var result = new List<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ partial class {{ class_name }}
[{{ name }}.Value] = {{ name }}{{ if !for.last }},{{ end }}
{{~ end ~}}
};
{{~ for prop in indexed_properties ~}}

private static readonly global::System.Collections.Generic.Dictionary<{{ prop.property_type_fully_qualified }}, {{ fully_qualified_class_name }}> s_by{{ prop.property_name }} =
new global::System.Collections.Generic.Dictionary<{{ prop.property_type_fully_qualified }}, {{ fully_qualified_class_name }}>({{ member_names.size }}{{ if prop.is_string_type }}, {{ prop.string_comparer_expression }}{{ end }})
{
{{~ for name in member_names ~}}
[{{ name }}.{{ prop.property_name }}] = {{ name }}{{ if !for.last }},{{ end }}
{{~ end ~}}
};
{{~ end ~}}

{{ generated_code_attribute }}
public static global::System.Collections.Generic.IReadOnlyList<{{ fully_qualified_class_name }}> All => s_all;
Expand All @@ -76,7 +86,7 @@ partial class {{ class_name }}
}

{{ generated_code_attribute }}
public static bool TryFromName(string name, out {{ fully_qualified_class_name }}? result) =>
public static bool TryFromName(string name, {{ if has_not_null_when_attribute }}[global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] {{ end }}out {{ fully_qualified_class_name }}? result) =>
s_byName.TryGetValue(name, out result);

{{ generated_code_attribute }}
Expand All @@ -90,12 +100,28 @@ partial class {{ class_name }}
}

{{ generated_code_attribute }}
public static bool TryFromValue({{ value_type_fully_qualified }} value, out {{ fully_qualified_class_name }}? result) =>
public static bool TryFromValue({{ value_type_fully_qualified }} value, {{ if has_not_null_when_attribute }}[global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] {{ end }}out {{ fully_qualified_class_name }}? result) =>
s_byValue.TryGetValue(value, out result);

{{ generated_code_attribute }}
public static bool ContainsName(string name) => s_byName.ContainsKey(name);

{{ generated_code_attribute }}
public static bool ContainsValue({{ value_type_fully_qualified }} value) => s_byValue.ContainsKey(value);
{{~ for prop in indexed_properties ~}}

{{ generated_code_attribute }}
public static {{ fully_qualified_class_name }} From{{ prop.property_name }}({{ prop.property_type_fully_qualified }} value)
{
if (!s_by{{ prop.property_name }}.TryGetValue(value, out var result))
throw new global::System.Collections.Generic.KeyNotFoundException(
$"'{value}' is not a valid {{ prop.property_name }} for {{ class_name }}");

return result;
}

{{ generated_code_attribute }}
public static bool TryFrom{{ prop.property_name }}({{ prop.property_type_fully_qualified }} value, {{ if has_not_null_when_attribute }}[global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] {{ end }}out {{ fully_qualified_class_name }}? result) =>
s_by{{ prop.property_name }}.TryGetValue(value, out result);
{{~ end ~}}
}{{ suffix }}
24 changes: 24 additions & 0 deletions src/LayeredCraft.OptimizedEnums/OptimizedEnumIndexAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#nullable enable

namespace LayeredCraft.OptimizedEnums;

/// <summary>
/// Marks a property on an intermediate OptimizedEnum base class as a lookup index.
/// The source generator will emit <c>From{PropertyName}</c> and <c>TryFrom{PropertyName}</c>
/// lookup methods backed by a pre-built dictionary on every concrete subclass.
/// </summary>
/// <remarks>
/// The property type must implement <see cref="IEquatable{T}"/>; otherwise diagnostic OE0202
/// is emitted and the index is skipped. For string properties, use
/// <see cref="StringComparison"/> to control key comparison.
/// </remarks>
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class OptimizedEnumIndexAttribute : Attribute
{
/// <summary>
/// For <see cref="string"/> properties, specifies the comparison used when building
/// the lookup dictionary. Defaults to <see cref="StringComparison.Ordinal"/>.
Comment on lines +13 to +20
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML docs use <see cref="StringComparison"/> / <see cref="StringComparison.Ordinal"/> inside a type that also defines a StringComparison property, which can make the cref binding ambiguous or incorrect in generated documentation. Consider fully qualifying the enum type in the cref (e.g., global::System.StringComparison) to ensure the links resolve as intended.

Suggested change
/// <see cref="StringComparison"/> to control key comparison.
/// </remarks>
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class OptimizedEnumIndexAttribute : Attribute
{
/// <summary>
/// For <see cref="string"/> properties, specifies the comparison used when building
/// the lookup dictionary. Defaults to <see cref="StringComparison.Ordinal"/>.
/// <see cref="global::System.StringComparison"/> to control key comparison.
/// </remarks>
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class OptimizedEnumIndexAttribute : Attribute
{
/// <summary>
/// For <see cref="string"/> properties, specifies the comparison used when building
/// the lookup dictionary. Defaults to <see cref="global::System.StringComparison.Ordinal"/>.

Copilot uses AI. Check for mistakes.
/// Ignored for non-string property types.
/// </summary>
public StringComparison StringComparison { get; set; } = StringComparison.Ordinal;
}
Loading
Loading