Bodu.Text.Formats.Generators - Reflection-free binding
Bodu.Text.Formats.Generators is an incremental Roslyn source generator that removes reflection from the Delimited and INI serializer paths. Annotate a partial POCO with [DelimitedRecord] or [IniSection] and the generator emits, at build time, an IDelimitedRecordFactory<TRecord> or IIniSectionFactory<TSection> implementation exposed as a static DelimitedFactory / IniFactory property on the type. Pass that factory to the serializer's factory overloads and the reflection binder is never entered - which is what makes the path safe for trimming and ahead-of-time compilation.
Part of the Text & Serialization topic, alongside the line formats.
Important
The package is Preview and not yet packable (IsPackable=false): it builds and tests as a Roslyn component inside the solution and is consumed by project reference today. It targets netstandard2.0 and pins Microsoft.CodeAnalysis.CSharp 4.8.0 as a private, build-time dependency. Packaging follows in a later release wave; nothing in the generated code or the consuming API will change when it does.
What the generator emits
For every annotated type the generator adds one file, <Namespace>.<Type>.Delimited.g.cs or <Namespace>.<Type>.Ini.g.cs, containing a second declaration of the partial type with:
- a public static property -
DelimitedFactoryof type IDelimitedRecordFactory<TRecord>, orIniFactoryof type IIniSectionFactory<TSection> - holding a singleton; - a private nested class implementing the interface: a static array of the resolved wire names in declaration order (
Headers/Keys), aGetFields/GetEntriesmethod that formats each member, aCreatemethod that constructs the instance and binds decoded strings back, and aBindhelper that matches names ordinally first and case-insensitively as a fallback.
The factory maps the type's public read/write instance properties in declaration order, honors [PropertyName] for the wire name, skips members annotated [Ignore] (with the default IgnoreCondition.Always), formats and parses scalars with InvariantCulture, and maps a Nullable<T> member to and from the empty string - mirroring the runtime reflection binders so the two paths are interchangeable.
Wiring the generator into a project
Reference the generator project as an analyzer, not as a runtime assembly, and reference the format package(s) whose marker attributes and factory interfaces the POCO uses:
<ItemGroup>
<ProjectReference Include="..\Bodu.Text.Delimited\src\Bodu.Text.Delimited.csproj" />
<ProjectReference Include="..\Bodu.Text.Ini\src\Bodu.Text.Ini.csproj" />
<ProjectReference Include="..\Bodu.Text.Formats.Generators\src\Bodu.Text.Formats.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
OutputItemType="Analyzer" makes the compiler load the generator into the consuming compilation; ReferenceOutputAssembly="false" keeps the generator out of the consumer's runtime references (the solution's own test project omits the second attribute only because its driver-based diagnostic tests instantiate the generator at runtime). Once the package ships, the conventional analyzer PackageReference form (PrivateAssets="all") replaces the project reference; the generated code is identical.
To inspect the emitted source, set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and look under obj/…/generated/Bodu.Text.Formats.Generators/.
Rules a POCO must satisfy
| Rule | Why |
|---|---|
| Annotated with DelimitedRecordAttribute or IniSectionAttribute (a class, struct, record, or record struct). | The generator keys on the attribute's metadata name. |
Every declaration of the type - and of every containing type - is partial. |
The factory is added as a second partial declaration (BTFG001 otherwise). |
| Not generic, and not nested inside a generic type. | A static factory property cannot be expressed for an open type (BTFG003 otherwise). |
Constructible with new T() - a parameterless constructor reachable from inside the type. |
Create instantiates the type before binding; the nested factory can reach a private constructor. |
Mapped members are public instance properties with a public getter and a public, non-init setter. |
Static members, indexers, non-public accessors, init-only setters, and fields are not mapped (silently). |
Each mapped property's type is a supported scalar or its Nullable<T>: string, bool, char, sbyte/byte/short/ushort/int/uint/long/ulong, float/double/decimal, Guid, DateTime, DateTimeOffset, TimeSpan, or any enum. |
Anything else - Uri, collections, nested objects - is skipped with BTFG002; the factory is still generated for the remaining members. |
[Ignore] with the default condition excludes a member; [Ignore(Condition = …)] with any other condition leaves it mapped. |
The factory has no write-time conditional path - the wire is string-only. |
Two behaviors differ from the reflection binder by design: the options-level PropertyNamingPolicy is not applied by a factory (its header and key names are fixed at compile time, so pin names with [PropertyName]), and IncludeFields has no effect (fields are never mapped).
Diagnostics
All three diagnostics share the category Bodu.Text.Formats.Generators and are enabled by default.
| Id | Severity | Message | Cause |
|---|---|---|---|
BTFG001 |
Error | The type '{0}' is annotated with [{1}] but it (or a containing type) is not declared partial, so no factory is generated | The annotated type, or one of the types it is nested in, lacks the partial modifier. No source is emitted. |
BTFG002 |
Warning | The property '{0}' on '{1}' has type '{2}', which is not a supported scalar; the generated factory skips it | A public read/write property has a type outside the scalar set above. The factory is emitted without that member. |
BTFG003 |
Error | The type '{0}' is annotated with [{1}] but is generic (or nested in a generic type), so no factory is generated | The annotated type has type parameters, or a containing type does. No source is emitted. |
Because BTFG001 and BTFG003 are errors, the missing DelimitedFactory / IniFactory property surfaces as a compile error at the call site as well; fix the declaration rather than suppressing the diagnostic.
The serializer overloads that consume a factory
The factory overloads are exact counterparts of the reflection entry points, with the factory as an extra parameter, and carry no trimming annotations:
| Serializer | Factory overloads |
|---|---|
| DelimitedSerializer | Serialize<TRecord>(IEnumerable<TRecord> records, IDelimitedRecordFactory<TRecord> factory, DelimitedSerializerOptions? options = null) → string; Serialize<TRecord>(IBufferWriter<byte> destination, IEnumerable<TRecord> records, IDelimitedRecordFactory<TRecord> factory, …); Deserialize<TRecord>(string text, IDelimitedRecordFactory<TRecord> factory, …), Deserialize<TRecord>(ReadOnlySpan<byte> utf8Delimited, …), and Deserialize<TRecord>(Stream source, …) → List<TRecord>. |
| IniSerializer | SerializeSection<TSection>(string sectionName, TSection value, IIniSectionFactory<TSection> factory, IniSerializerOptions? options = null) → string; SerializeSection<TSection>(IBufferWriter<byte> destination, string sectionName, TSection value, IIniSectionFactory<TSection> factory, …); DeserializeSection<TSection>(string text, string sectionName, IIniSectionFactory<TSection> factory, …) and DeserializeSection<TSection>(ReadOnlySpan<byte> utf8Ini, string sectionName, …) → TSection. |
Dialect options still apply on the factory path - Delimiter, Quote, and NoHeader for Delimited (a headerless document binds positionally in Headers order), and the duplicate-section / duplicate-key policies for INI (merging runs before the factory sees the entries). An empty INI section name writes or binds the document's global keys; a section that is absent from the input raises IniSerializationException. DotEnvSerializer has no factory surface - it remains reflection-only.
Writing a factory by hand
The interfaces are small enough to implement directly when the generator cannot be used - a type you do not own, a non-scalar column, or a build that cannot host analyzers. The contract for Delimited: Headers (column names in field order), GetFields(record) (values in Headers order), and Create(fields, headers) where headers is the document's header row, or empty for a headerless document, in which case bind positionally.
using System.Globalization;
using Bodu.Text.Delimited;
public sealed class TradeFactory : IDelimitedRecordFactory<Trade>
{
private static readonly string[] s_headers = ["Symbol", "Quantity", "Price", "traded_at", "Venue"];
public IReadOnlyList<string> Headers => s_headers;
public string[] GetFields(Trade record) =>
[
record.Symbol,
record.Quantity.ToString(CultureInfo.InvariantCulture),
record.Price.ToString(CultureInfo.InvariantCulture),
record.TradedAt.ToString(CultureInfo.InvariantCulture),
record.Venue ?? string.Empty,
];
public Trade Create(string[] fields, IReadOnlyList<string> headers)
{
var trade = new Trade();
IReadOnlyList<string> names = headers.Count == 0 ? s_headers : headers;
for (int i = 0; i < fields.Length && i < names.Count; i++)
{
switch (names[i])
{
case "Symbol": trade.Symbol = fields[i]; break;
case "Quantity": trade.Quantity = int.Parse(fields[i], CultureInfo.InvariantCulture); break;
case "Price": trade.Price = decimal.Parse(fields[i], CultureInfo.InvariantCulture); break;
case "traded_at": trade.TradedAt = DateTimeOffset.Parse(fields[i], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); break;
case "Venue": trade.Venue = fields[i].Length == 0 ? null : fields[i]; break;
}
}
return trade;
}
}
Format with InvariantCulture on the way out and parse with it on the way in, and the hand-written factory produces byte-identical output to both the generated factory and the reflection binder. The INI contract is the same shape over key/value pairs: Keys, GetEntries(section) returning IEnumerable<KeyValuePair<string, string>>, and Create(entries).
Trimming and AOT
The reflection entry points of all three line-format serializers are annotated: DelimitedSerializer.Serialize<T> / Deserialize<TRecord> / SerializeAsync / DeserializeAsyncEnumerableAsync, IniSerializer.Serialize<T> / Deserialize<T> and their stream and async variants, and every DotEnvSerializer entry point carry [RequiresUnreferencedCode] and [RequiresDynamicCode]. A project that publishes trimmed or native-AOT therefore reports IL2026 / IL3050 at each reflection call site. The factory overloads carry neither attribute, so routing Delimited and INI binding through a generated (or hand-written) factory is the supported way to publish trimmed or AOT-compiled binaries with these packages. The structured serializers are annotated as well - TomlSerializer with both attributes, YamlSerializer with [RequiresUnreferencedCode] - and have no reflection-free path today.
Where to go next
- Source generator guide - the end-to-end walk-through: POCO, generated factory, both serializers, parity, diagnostics.
- Line formats introduction, Core concepts, and Getting started - the umbrella trio.
- Bodu.Text.Delimited and Bodu.Text.Ini - the two packages whose serializers accept a factory.
- Package matrix - status and dependencies.