Table of Contents

Reflection-free binding with the source generator

Bodu.Text.Formats.Generators emits compile-time factories for [DelimitedRecord] and [IniSection] POCOs so that DelimitedSerializer and IniSerializer can bind without reflection. This guide walks one record type and one section type end to end: annotate, build, use the generated factory with the factory overloads, confirm byte parity with the reflection binder, and read the diagnostics. The reference material - csproj wiring, the full rule list, the diagnostics table, trimming - is on the package page.

Note

The generator is not yet published as a package; reference the project as an analyzer (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), together with Bodu.Text.Delimited and/or Bodu.Text.Ini for the marker attributes and factory interfaces.

Step 1 - annotate a partial POCO

The rules that matter: the type is partial, non-generic, has a parameterless constructor, and every mapped member is a public read/write property of a supported scalar type. [PropertyName] renames a column; [Ignore] drops one.

using Bodu.Text.Delimited;
using Bodu.Text.Serialization;

[DelimitedRecord]
public sealed partial class Trade
{
    public string Symbol { get; set; } = "";
    public int Quantity { get; set; }
    public decimal Price { get; set; }

    [PropertyName("traded_at")]
    public DateTimeOffset TradedAt { get; set; }

    public string? Venue { get; set; }

    [Ignore]
    public string? Notes { get; set; }
}

Step 2 - build, and look at what was generated

Building the project adds Trade.DelimitedFactory. Its Headers are the resolved wire names in declaration order - the renamed column and the omitted Notes member are already visible here:

Trade.DelimitedFactory.Headers   // "Symbol", "Quantity", "Price", "traded_at", "Venue"

The emitted source (visible with <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>) is a second partial declaration of Trade holding the static property and a private nested implementation. Abridged:

// <auto-generated/>
partial class Trade
{
    public static global::Bodu.Text.Delimited.IDelimitedRecordFactory<Trade> DelimitedFactory { get; } = new GeneratedDelimitedRecordFactory();

    private sealed class GeneratedDelimitedRecordFactory : global::Bodu.Text.Delimited.IDelimitedRecordFactory<Trade>
    {
        private static readonly string[] HeaderNames = new string[] { "Symbol", "Quantity", "Price", "traded_at", "Venue" };

        public global::System.Collections.Generic.IReadOnlyList<string> Headers => HeaderNames;

        public string[] GetFields(Trade record)
        {
            return new string[]
            {
                record.Symbol ?? string.Empty,
                record.Quantity.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
                record.Price.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
                record.TradedAt.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
                record.Venue ?? string.Empty,
            };
        }

        public Trade Create(string[] fields, global::System.Collections.Generic.IReadOnlyList<string> headers)
        {
            var result = new Trade();
            if (headers.Count == 0)
            {
                // headerless document: bind positionally in HeaderNames order
                // …
                return result;
            }

            int limit = fields.Length < headers.Count ? fields.Length : headers.Count;
            for (int i = 0; i < limit; i++)
                Bind(result, headers[i], fields[i]);

            return result;
        }

        private static void Bind(Trade result, string name, string value)
        {
            switch (name)
            {
                case "Quantity":
                    result.Quantity = int.Parse(value, global::System.Globalization.CultureInfo.InvariantCulture);
                    return;
                // … one case per column, then a case-insensitive fallback …
            }
        }
    }
}

Every conversion is explicit and invariant-culture; nothing in the file touches System.Reflection.

Step 3 - serialize and deserialize through the factory

The factory overloads take the factory as an extra argument and are otherwise the same entry points:

using Bodu.Text.Delimited;

var trades = new List<Trade>
{
    new() { Symbol = "MSFT", Quantity = 100, Price = 412.5m, TradedAt = new DateTimeOffset(2026, 3, 2, 9, 30, 0, TimeSpan.Zero), Venue = "XNAS", Notes = "internal" },
    new() { Symbol = "AAPL", Quantity = 25, Price = 189.99m, TradedAt = new DateTimeOffset(2026, 3, 2, 9, 31, 0, TimeSpan.Zero), Venue = null },
};

string csv = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory);
// Symbol,Quantity,Price,traded_at,Venue
// MSFT,100,412.5,03/02/2026 09:30:00 +00:00,XNAS
// AAPL,25,189.99,03/02/2026 09:31:00 +00:00,

List<Trade> back = DelimitedSerializer.Deserialize(csv, Trade.DelimitedFactory);
// back[0].TradedAt → 2026-03-02T09:30:00+00:00; back[1].Venue → null (empty field)

Dialect options apply unchanged - a TSV or headerless variant is one options object away:

var tsv = new DelimitedSerializerOptions { Delimiter = '\t' };
string tab = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory, tsv);

var headerless = new DelimitedSerializerOptions { NoHeader = true };
List<Trade> positional = DelimitedSerializer.Deserialize(
    "IBM,10,1.5,2026-01-01T00:00:00+00:00,\n", Trade.DelimitedFactory, headerless);
// binds by position in Headers order → Symbol = "IBM", Quantity = 10

Step 4 - the same for an INI section

using Bodu.Text.Ini;
using Bodu.Text.Serialization;

[IniSection]
public sealed partial class ServerSection
{
    public string Host { get; set; } = "";
    public int Port { get; set; }

    [PropertyName("use_tls")]
    public bool UseTls { get; set; }

    public TimeSpan? Timeout { get; set; }
}
var server = new ServerSection { Host = "db.example.com", Port = 5432, UseTls = true, Timeout = TimeSpan.FromSeconds(90) };

string ini = IniSerializer.SerializeSection("server", server, ServerSection.IniFactory);
// [server]
// Host=db.example.com
// Port=5432
// use_tls=true
// Timeout=00:01:30

ServerSection restored = IniSerializer.DeserializeSection(ini, "server", ServerSection.IniFactory);

// An empty section name addresses the document's global keys instead of a [section].
string globals = IniSerializer.SerializeSection("", server, ServerSection.IniFactory);
// Host=db.example.com
// Port=5432
// …

ServerSection.IniFactory.Keys is Host, Port, use_tls, Timeout. A null nullable writes as an empty value (Timeout=) and an empty value reads back as null. Asking for a section the document does not contain raises IniSerializationException; the configured duplicate-section and duplicate-key policies are applied before the factory sees the entries.

Byte parity with the reflection binder

The generated factory is designed to be a drop-in for the reflection path, and the solution's tests pin that: serializing the same records through DelimitedSerializer.Serialize(records) and DelimitedSerializer.Serialize(records, Trade.DelimitedFactory) yields identical text - same header names, same column order, same invariant scalar formatting, same quoting. The INI factory writes the same canonical [section] / key=value bytes the reflection binder emits for a section POCO.

string reflection = DelimitedSerializer.Serialize(trades);                          // [RequiresUnreferencedCode]
string generated  = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory);  // no annotations
bool same = reflection == generated;                                                // true

The one deliberate divergence: a factory's names are fixed at compile time, so PropertyNamingPolicy on the options is ignored by the factory overloads. Use [PropertyName] to pin wire names, and both paths agree.

Diagnostics walk-through

The generator reports three diagnostics, all in category Bodu.Text.Formats.Generators.

BTFG001 (error) - the type is not partial. The most common first-run failure: the attribute is present, the partial modifier is not. No factory is generated, so Trade.DelimitedFactory also fails to resolve.

[DelimitedRecord]
public sealed class Trade { … }          // BTFG001: … is not declared partial, so no factory is generated

A nested type must have every containing type marked partial too.

BTFG002 (warning) - a member type is not a supported scalar. The factory is still generated; the flagged member is left out of Headers / Keys and never written or read. Either move the value to a supported scalar (for example, format a Uri as a string), or drop the member with [Ignore] to silence the warning deliberately.

[DelimitedRecord]
public sealed partial class Order
{
    public int Id { get; set; }
    public List<string> Tags { get; set; } = [];   // BTFG002: type 'List<string>' is not a supported scalar; the generated factory skips it
}

BTFG003 (error) - the type is generic. A static DelimitedFactory property cannot exist on an open generic, and the same applies to a type nested inside a generic type. No source is emitted; close the type over its arguments or write the factory by hand.

[IniSection]
public sealed partial class Section<T> { … }   // BTFG003: … is generic (or nested in a generic type), so no factory is generated

When the generator cannot be used

Implement IDelimitedRecordFactory<TRecord> or IIniSectionFactory<TSection> yourself - the package page shows a complete hand-written Trade factory that is byte-identical to the generated one. The factory overloads accept any implementation.

See also