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
95 changes: 95 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Changelog

All notable changes to FancyEnumGenerator. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project uses [Semantic Versioning](https://semver.org/). Before 1.0, a minor version bump (0.1 → 0.2) can
include breaking changes, and each one comes with upgrade notes.

## [0.2.0] - 2026-10-01

Thanks to [the suggestion on Reddit](https://www.reddit.com/r/moderndotnet/comments/1wu7mrh/comment/pd7erju/) to return a
`ReadOnlySpan<T>` from `Values` instead of an inline array: since a span over constants is static data, nothing needs
to be copied.

### Changed

- **`Values` is now a `ReadOnlySpan<T>` by default.** The compiler stores the members as static data in your
assembly, so `Values` no longer copies anything or allocates. This needs .NET 7+, except for enums with a 1-byte
underlying type (`byte`, `sbyte`), which now get `Values` on every target, .NET Framework and netstandard2.0
included. In 0.1, `Values` was a fresh `[InlineArray]` copy on every access and needed .NET 8+.
- **The new `ValuesType` setting chooses what `Values` returns**, with the same precedence as every other setting: on
`[FancyEnum]`, on `[assembly: FancyEnumDefaults]` or as the MSBuild property `FancyEnumValuesType`. The options are
`Span` (the default), `InlineArray` (0.1's default: a storable copy) and `StaticCollection` (a cached
`IReadOnlyList<T>`). `InlineArray` and `StaticCollection` also generate `AsSpan`; with `Span`, `Values` already is
the span.
- **`NoInlineArray` now only controls the `[InlineArray]` struct.** Flags enums lose `ListFlagMembers` with it, and
`ValuesType = InlineArray` isn't available alongside it.
- **The `{Enum}Array` struct is only generated when something uses it:** flags enums, for `ListFlagMembers`, and
`ValuesType = InlineArray`.

### Removed

- **`CreateStaticReadonlyCollection`**, on both attributes and as the MSBuild property
`FancyEnumCreateStaticReadonlyCollection`. Use `ValuesType = FancyEnumValuesType.StaticCollection`.

### Added

- **`FancyEnumValuesType`**, the enum behind `ValuesType`.
- **HENUM017 (warning):** an explicitly chosen `ValuesType` can't be generated for the current target. That's `Span` on
an enum wider than a byte before .NET 7, `InlineArray` before .NET 8, or `InlineArray` with `NoInlineArray`.
`Values` is left out for that target only, so a multi-targeted project still builds. The default never warns.

### Fixed

- **An enum named `Inline` no longer breaks the build.** Its generated `InlineArray` struct shadowed the attribute alias
in the generated file (CS0616). The attribute is now fully qualified.

### Upgrading from 0.1

Most code needs no changes: `foreach (var value in MyEnum.Values)` and `MyEnum.Values[i]` work the same with every
`ValuesType`. What does need attention:

| If your 0.1 code... | ...do this in 0.2 |
|---|---|
| Sets `CreateStaticReadonlyCollection = true` | Set `ValuesType = FancyEnumValuesType.StaticCollection`. `AsSpan` is unchanged. `Values` becomes an `IReadOnlyList<T>`, where on .NET 8+ it used to be a cached inline-array copy. |
| Sets `<FancyEnumCreateStaticReadonlyCollection>true</FancyEnumCreateStaticReadonlyCollection>` | Use `<FancyEnumValuesType>StaticCollection</FancyEnumValuesType>`. |
| Stores the default `Values` in a field, keeps it across an `await`, captures it in a lambda or uses it in an iterator | A span can't do that. Set `ValuesType = FancyEnumValuesType.InlineArray` (exactly 0.1's behavior) or `StaticCollection` (no copy at all), or call `.ToArray()`. |
| Assigns the default `Values` to a `Span<T>` | Use `ReadOnlySpan<T>`. The members are read-only data now. |
| Names the `{Enum}Array` type for a non-flags enum, e.g. `FruitArray values = Fruit.Values;` | Use `var` or `ReadOnlySpan<Fruit>`, or set `ValuesType = InlineArray` to keep the struct. |
| Uses `AsSpan` on an enum that only had it because of `CreateStaticReadonlyCollection` | Either keep it with `ValuesType = StaticCollection`, or move to the default and use `Values`, which is the span. |
| Combines `NoInlineArray = true` with `CreateStaticReadonlyCollection = true` | Use `ValuesType = StaticCollection`. Keep `NoInlineArray` only if you also want flags enums without `ListFlagMembers`. |

To keep 0.1's `Values` behavior across a whole repo while you migrate, set this in `Directory.Build.props`:

```xml
<PropertyGroup>
<FancyEnumValuesType>InlineArray</FancyEnumValuesType>
</PropertyGroup>
```

A note on Debug builds: the JIT doesn't optimize `RuntimeHelpers.CreateSpan` in unoptimized code, so the default
`Values` allocates in a Debug build. Release builds are allocation-free.

## [0.1.2] - 2026-09-29

### Added

- **`FancyEnumMemberSetItem.NotDefined`:** a string member-set property can fall back to the member's name (`NameOf`,
`NameOfLower`, `NameOfUpper`) for members that don't set it, like `NotDefined` on `[FancyEnumMemberMappingSettings]`.
It works with `ParseFrom`, `CreateTryFormat` and `IncludeUtf8Value`. `DefaultValue` wins if both are set. On a
non-string property it reports HENUM012 and is ignored.

## [0.1.1] - 2026-09-29

### Changed

- Documentation only: full benchmark results, generated from the BenchmarkDotNet reports, and refreshed performance
numbers in the README.

## [0.1.0] - 2026-09-29

- First public release.

[0.2.0]: https://github.com/htmlcsstoimage/FancyEnumGenerator/compare/v0.1.2...v0.2.0
[0.1.2]: https://github.com/htmlcsstoimage/FancyEnumGenerator/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/htmlcsstoimage/FancyEnumGenerator/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/htmlcsstoimage/FancyEnumGenerator/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<Version>0.1.2</Version>
<Version>0.2.0</Version>
</PropertyGroup>

<!--
Expand Down
1 change: 1 addition & 0 deletions FancyEnumGenerator.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<File Path="global.json" />
<File Path="LICENSE" />
<File Path="README.md" />
<File Path="CHANGELOG.md" />
</Folder>
<Folder Name="/.github/" />
<Folder Name="/.github/workflows/">
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Fruit.TryParseFancy("Banana", out var f); // true, f == Fruit.Banana
Fruit.Apple.IsUnknown; // false
((Fruit)7).IsUnknown; // true
Fruit.Unknown.IsUnknown; // true
Fruit.Values; // every non-Unknown member (Apple, Banana, Cherry)
Fruit.Values; // every non-Unknown member (Apple, Banana, Cherry), as a ReadOnlySpan
```

`[FancyEnum]` enforces two rules by default (both can be relaxed — see [Settings](#settings)):
Expand Down Expand Up @@ -143,7 +143,7 @@ The settings you're most likely to change:
| `DefaultToStringBehavior` | `NameOf` | What `ToStringFancy()` returns: the name, lower- or upper-cased, a field's value, or the member's `[Description]`, `[Display]`, `[EnumMember]` or `[JsonStringEnumMemberName]`. |
| `CreateByteParsing` | `false` | Add UTF-8 (`ReadOnlySpan<byte>`) parse overloads. |
| `CreateTryFormat` | `false` | Add `TryFormat(Span<char>, out int)`, which formats into your own buffer. |
| `CreateStaticReadonlyCollection` | `false` | Cache `Values` in a static field and add a zero-copy `AsSpan`. |
| `ValuesType` | `Span` | What `Values` returns: a zero-copy `ReadOnlySpan<T>` (`Span`), a storable inline-array copy (`InlineArray`) or a cached `IReadOnlyList<T>` (`StaticCollection`). |
| `IncludeObsolete` | `false` | Generate `[Obsolete]` members too; by default they're left out. |

Each can be set in three places. For each setting, the first of these that sets it wins:
Expand Down Expand Up @@ -306,6 +306,7 @@ references a static readonly field or parameterless static method instead of a l
| HENUM014 | A mapped field would generate a member whose name FancyEnum or `System.Enum` already uses (e.g. `Length`, `Values`, `ToString`), or that another field also generates. |
| HENUM015 | A member-set property can never be set: its type can't be an attribute argument, or it's read-only with no constructor parameter mapped to it. |
| HENUM016 (warning) | An enum uses a member-set attribute but has no `[FancyEnum]`, so nothing is generated for it. |
| HENUM017 (warning) | An explicitly chosen `ValuesType` can't be generated for this target (`Span` on a wider-than-byte enum before .NET 7, `InlineArray` before .NET 8 or with `NoInlineArray`), so `Values` is left out there. |

## Examples

Expand All @@ -329,13 +330,13 @@ Nothing here allocates except where noted.

## Footprint

- **No runtime state by default.** Generated members are `switch` expressions over constants and string literals, so an
enum adds no static fields and no heap allocations, even on first use. The one exception is the opt-in
`CreateStaticReadonlyCollection`: it adds one lazily filled buffer of N × the enum's size, or a plain array when
combined with `NoInlineArray`.
- **Code size, in exchange.** The generated code is compiled into your assembly. In a release build that's about 6.5 KB
for a 25-member enum with default settings, about 11.5 KB with UTF-8 parsing, `TryFormat` and the cached collection
all on, and about 38 KB for a 200-member enum. Much of that is parsing: exact and case-insensitive lookups each get
- **No runtime state by default.** Generated members are `switch` expressions over constants and string literals, and
`Values` is a span over data stored in your assembly, so an enum adds no static fields and no heap allocations, even
on first use. The one exception is the opt-in `ValuesType = StaticCollection`, which adds one array of the members,
created once.
- **Code size, in exchange.** The generated code is compiled into your assembly. In a release build that's about 6 KB
for a 25-member enum with default settings, about 10 KB with UTF-8 parsing, `TryFormat` and a cached collection
all on, and about 36 KB for a 200-member enum. Much of that is parsing: exact and case-insensitive lookups each get
their own set of `switch` cases. On top of that comes about 1.5 KB of shared parsing helpers, once per assembly. The
BCL and reflection-based libraries add no code to your assembly; instead they build their caches at runtime.

Expand All @@ -346,7 +347,7 @@ Nothing here allocates except where noted.

## About

FancyEnumGenerator is built and maintained by [HTML/CSS to Image](https://htmlcsstoimage.com), an API for turning HTML/CSS or a URL into an image. We use it for the 150-plus enums across our own codebase. For the story behind it, and how the parsers got fast, see [the blog post](https://htmlcsstoimage.com/blog/fancy-enum-generator).
FancyEnumGenerator is built and maintained by [HTML/CSS to Image](https://htmlcsstoimage.com), an API for turning HTML/CSS or a URL into an image. We use it for the 150-plus enums across our own codebase. For the story behind it, and how the parsers got fast, see [the blog post](https://htmlcsstoimage.com/blog/fancy-enum-generator). Release notes and upgrade guides are in [CHANGELOG.md](https://github.com/htmlcsstoimage/FancyEnumGenerator/blob/main/CHANGELOG.md).

## License

Expand Down
62 changes: 61 additions & 1 deletion benchmarks/FancyEnumGenerator.Benchmarks/Subjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class StationInfoAttribute : Attribute
public int Order { get; set; }
}

[FancyEnum(CreateByteParsing = true, CreateTryFormat = true, CreateStaticReadonlyCollection = true)]
[FancyEnum(CreateByteParsing = true, CreateTryFormat = true)]
public enum Medium
{
Unknown = 0,
Expand Down Expand Up @@ -565,6 +565,66 @@ public enum MediumDescribed
[Description("early valley")] EarlyValley = 24,
}

[FancyEnum(ValuesType = FancyEnumValuesType.InlineArray)]
public enum MediumInline
{
Unknown = 0,
AmberHarbor = 1,
IronHarbor = 2,
QuietHarbor = 3,
EarlyFalcon = 4,
MistyFalcon = 5,
AmberMeadow = 6,
IronMeadow = 7,
QuietMeadow = 8,
EarlySummit = 9,
MistySummit = 10,
AmberCanyon = 11,
IronCanyon = 12,
QuietCanyon = 13,
EarlyRiver = 14,
MistyRiver = 15,
AmberForest = 16,
IronForest = 17,
QuietForest = 18,
EarlyBeacon = 19,
MistyBeacon = 20,
AmberGlacier = 21,
IronGlacier = 22,
QuietGlacier = 23,
EarlyValley = 24,
}

[FancyEnum(ValuesType = FancyEnumValuesType.StaticCollection)]
public enum MediumCached
{
Unknown = 0,
AmberHarbor = 1,
IronHarbor = 2,
QuietHarbor = 3,
EarlyFalcon = 4,
MistyFalcon = 5,
AmberMeadow = 6,
IronMeadow = 7,
QuietMeadow = 8,
EarlySummit = 9,
MistySummit = 10,
AmberCanyon = 11,
IronCanyon = 12,
QuietCanyon = 13,
EarlyRiver = 14,
MistyRiver = 15,
AmberForest = 16,
IronForest = 17,
QuietForest = 18,
EarlyBeacon = 19,
MistyBeacon = 20,
AmberGlacier = 21,
IronGlacier = 22,
QuietGlacier = 23,
EarlyValley = 24,
}

[FancyEnum]
public enum Station
{
Expand Down
40 changes: 33 additions & 7 deletions benchmarks/FancyEnumGenerator.Benchmarks/ValueBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ public void Setup()
}

/// <summary>
/// Enumerating every member (summing the values, so the loop can't be elided). The BCL and NetEscapades both return a
/// new array per call; on .NET 10 the JIT can stack-allocate NetEscapades' array here, because once inlined it never
/// escapes this loop, but code that stores or passes the array on still pays for it. FancyEnum's Values/AsSpan exclude
/// the Unknown member, so it walks 24 members where the others walk 25.
/// Enumerating every member (summing the values, so the loop can't be elided), once per FancyEnum ValuesType. The BCL
/// and NetEscapades both return a new array per call; on .NET 10 the JIT can stack-allocate NetEscapades' array here,
/// because once inlined it never escapes this loop, but code that stores or passes the array on still pays for it.
/// FancyEnum's Values/AsSpan exclude the Unknown member, so it walks 24 members where the others walk 25.
/// </summary>
[Config(typeof(BenchmarkConfig))]
public class ValuesBenchmarks
Expand All @@ -44,8 +44,9 @@ public int Bcl()
return sum;
}

/// <summary>The default: a span over static data in the assembly.</summary>
[Benchmark]
public int FancyEnum_Values()
public int FancyEnum_Span()
{
var sum = 0;
foreach (var value in Medium.Values)
Expand All @@ -55,11 +56,36 @@ public int FancyEnum_Values()
return sum;
}

/// <summary>A fresh inline-array copy per call, which can be stored or kept across an await.</summary>
[Benchmark]
public int FancyEnum_AsSpan()
public int FancyEnum_InlineArray()
{
var sum = 0;
foreach (var value in Medium.AsSpan)
foreach (var value in MediumInline.Values)
{
sum += (int)value;
}
return sum;
}

/// <summary>The cached IReadOnlyList, enumerated through the interface.</summary>
[Benchmark]
public int FancyEnum_StaticCollection()
{
var sum = 0;
foreach (var value in MediumCached.Values)
{
sum += (int)value;
}
return sum;
}

/// <summary>The span over the cached array that StaticCollection also generates.</summary>
[Benchmark]
public int FancyEnum_StaticCollection_AsSpan()
{
var sum = 0;
foreach (var value in MediumCached.AsSpan)
{
sum += (int)value;
}
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/combine_results.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
("IsDefinedBenchmarks", "Is a value declared?",
"For a contiguous and a sparse enum. FancyEnum's check is `!IsUnknown`."),
("ValuesBenchmarks", "Enumerating every member",
"Summing every member's value. FancyEnum's `Values`/`AsSpan` leave out `Unknown`, so they walk 24 members where the others walk 25. NetEscapades' `GetValues()` returns a new array per call, which .NET 10's JIT can stack-allocate here because the array never leaves the loop."),
"Summing every member's value, once for each FancyEnum `ValuesType`: the default `Span`, `InlineArray` (a storable copy) and `StaticCollection` (a cached `IReadOnlyList`, also through its `AsSpan`). FancyEnum leaves out `Unknown`, so it walks 24 members where the others walk 25. NetEscapades' `GetValues()` returns a new array per call, which .NET 10's JIT can stack-allocate here because the array never leaves the loop."),
("TryFormatBenchmarks", "Formatting into a buffer",
"`TryFormat` into a caller-supplied `Span<char>`."),
];
Expand Down
5 changes: 4 additions & 1 deletion benchmarks/generate_subjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public sealed class StationInfoAttribute : Attribute
// Compared across all libraries: (name, FancyEnum attributes, body), each also emitted as Ne{name} for NetEscapades.
(string Name, string Fancy, string Body)[] shared =
[
("Medium", "[FancyEnum(CreateByteParsing = true, CreateTryFormat = true, CreateStaticReadonlyCollection = true)]", unknown + Members(medium, description)),
("Medium", "[FancyEnum(CreateByteParsing = true, CreateTryFormat = true)]", unknown + Members(medium, description)),
("Large", "[FancyEnum]", unknown + Members(large)),
];
foreach (var (name, fancy, body) in shared)
Expand All @@ -66,6 +66,9 @@ public sealed class StationInfoAttribute : Attribute
// FancyEnum picks its ToString source per enum, so descriptions get their own copy of Medium; the BCL reads
// [Description] off Medium by reflection for the same comparison.
parts.Add(Enum("MediumDescribed", "[FancyEnum(DefaultToStringBehavior = FancyEnumDefaultToStringBehavior.DescriptionAttribute)]", unknown + Members(medium, description)));
// The same members with each of the other ValuesType options, for the enumeration benchmark (Medium uses the default, Span).
parts.Add(Enum("MediumInline", "[FancyEnum(ValuesType = FancyEnumValuesType.InlineArray)]", unknown + Members(medium)));
parts.Add(Enum("MediumCached", "[FancyEnum(ValuesType = FancyEnumValuesType.StaticCollection)]", unknown + Members(medium)));
// Custom per-member metadata: FancyEnum generates accessors; the BCL reads the attribute at runtime.
parts.Add(Enum("Station", "[FancyEnum]", unknown + Members(medium, stationInfo)));

Expand Down
Loading
Loading