FluentRoslyn.Templates 0.1.0-preview.9

Prefix Reserved
This is a prerelease version of FluentRoslyn.Templates.
dotnet add package FluentRoslyn.Templates --version 0.1.0-preview.9
                    
NuGet\Install-Package FluentRoslyn.Templates -Version 0.1.0-preview.9
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FluentRoslyn.Templates" Version="0.1.0-preview.9">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FluentRoslyn.Templates" Version="0.1.0-preview.9" />
                    
Directory.Packages.props
<PackageReference Include="FluentRoslyn.Templates">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FluentRoslyn.Templates --version 0.1.0-preview.9
                    
#r "nuget: FluentRoslyn.Templates, 0.1.0-preview.9"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FluentRoslyn.Templates@0.1.0-preview.9
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FluentRoslyn.Templates&version=0.1.0-preview.9&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=FluentRoslyn.Templates&version=0.1.0-preview.9&prerelease
                    
Install as a Cake Tool

<p align="center"> <img src="https://raw.githubusercontent.com/keybindings/FluentRoslyn/main/assets/readme-banner.png" alt="FluentRoslyn — readable source generators" width="820" /> </p>

CI License: MIT

A fluent C# API for generating C# source code — a readable facade over Roslyn's SyntaxFactory.

You describe the code you want with a builder chain; FluentRoslyn produces a well-formed syntax tree and formats it. Because it builds real syntax nodes rather than concatenating strings, whole classes of bugs — misplaced braces, missing commas, bad spacing — are structurally impossible.

var user = NamespaceBuilder.Get("MyApp.Models").Class("User");
var id   = user.DefineProperty<int>("Id").GetOnly();
var name = user.DefineProperty<string>("Name");

user.DefineConstructor(AccessModifier.Public)
    .WithParameter<int>("id",      out var idParam)
    .WithParameter<string>("name", out var nameParam)
    .Assign(id, idParam)
    .Assign(name, nameParam);

var code = user.ToString();
namespace MyApp.Models;
public class User
{
    public User(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public int Id { get; }
    public string Name { get; set; }
}

Why

Roslyn already ships a fluent code-building layer — SyntaxGenerator — but it lives in Microsoft.CodeAnalysis.Workspaces, and the compiler does not ship that assembly. The SDK's compiler directory contains only:

Microsoft.CodeAnalysis.dll
Microsoft.CodeAnalysis.CSharp.dll
Microsoft.CodeAnalysis.VisualBasic.dll

Source generators run inside that process, so they can only bind against what it provides. The failure is worse than a compile error, because nothing stops you trying: referencing Workspaces from a generator compiles cleanly — no warning, even with EnforceExtendedAnalyzerRules — and the consuming build still reports success. The generator just dies at generation time and contributes nothing:

warning CS8785: Generator 'MyGenerator' failed to generate source. Exception was
of type 'FileNotFoundException' with message 'Could not load file or assembly
'Microsoft.CodeAnalysis.Workspaces, Version=4.9.0.0 ...'

A warning, easily scrolled past, with your generated file silently missing.

So generator authors are left hand-writing verbose SyntaxFactory calls or, more often, concatenating strings and fighting formatting bugs. FluentRoslyn fills that gap: an intention-revealing builder API targeting netstandard2.0, so it works where SyntaxGenerator cannot. The goal is generator code that reads like the code it produces.

Requirements

  • Target framework: netstandard2.0 (usable from source generators)
  • Roslyn: Microsoft.CodeAnalysis.CSharp 4.9.2+ on the consuming side
  • Output: 4-space indentation, \n line endings (byte-identical across operating systems)

Status: published as FluentRoslyn on nuget.org, currently 0.1.0-preview.9, with the optional companion package FluentRoslyn.Templates versioned alongside it. Breaking changes are still on the table while the version says preview. See What's next.

Building blocks

Everything starts from a NamespaceBuilder and flows into a type builder, then member builders:

NamespaceBuilder ──▶ Class / Struct / Record / Enum / Interface
                         └──▶ DefineField / DefineConstructor / DefineProperty / DefineMethod

Every builder is fluent (each With…/Define… returns a builder for chaining) and renders three ways:

Call Returns
.ToString() the formatted source as a string
.ToSourceText() a Roslyn SourceText (UTF-8) for context.AddSource(...)
.BuildCompilationUnit() the raw CompilationUnitSyntax (escape hatch)

Examples

Positional record

NamespaceBuilder.Get("MyApp").Record("Point")
    .WithParameter<int>("X")
    .WithParameter<int>("Y");
namespace MyApp;
public record Point(int X, int Y);

Use .AsStruct() for a record struct.

Enum

NamespaceBuilder.Get("MyApp").Enum("Access")
    .WithAttribute("Flags")
    .WithUnderlyingType<byte>()
    .AddMember("None", 0)
    .AddMember("Read", 1)
    .AddMember("Write", 2);
namespace MyApp;
[Flags]
public enum Access : byte
{
    None = 0,
    Read = 1,
    Write = 2
}

Member values are validated against the underlying type, and member names must be unique — invalid input throws rather than emitting code that won't compile.

Interface with generics

var repo = NamespaceBuilder.Get("MyApp").Interface("IRepository")
    .WithTypeParameter("T")
    .WithConstraint("T", "class");
repo.DefineMethod<int>("Count");
repo.DefineMethod("Add").WithParameter<int>("id");
namespace MyApp;
public interface IRepository<T>
    where T : class
{
    int Count();
    void Add(int id);
}

Constraints are emitted in the order C# requires (class/struct first, new() last) regardless of the order you add them.

Method bodies

void methods default to an empty block; value-returning methods need a body:

var calc = NamespaceBuilder.Get("MyApp").Class("Calc");
calc.DefineMethod<int>("Add")
    .WithParameter<int>("a")
    .WithParameter<int>("b")
    .AsExpressionBody("a + b");
namespace MyApp;
public class Calc
{
    public int Add(int a, int b) => a + b;
}

Statement bodies use .AddStatement("…") / .WithBody("…", "…"). Properties support the same forms plus initializers, init, get-only, and per-accessor access modifiers.

Typed references

The opening example's Assign calls are the typed alternative to AddStatement("Id = id;") — raw text that nothing checks. Property and field builders are references; a parameter hands one back through an out argument, which keeps the fluent chain intact.

Assign takes two IReference<T> sharing one T, so Assign(name, idParam) is a compile error in your generator rather than generated code that won't build. And when a parameter shadows the member it targets, the member is qualified automatically:

var shadow = NamespaceBuilder.Get("MyApp").Class("Shadow");
var value = shadow.DefineProperty<string>("value");

shadow.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("value", out var valueParam)
    .Assign(value, valueParam);
namespace MyApp;
public class Shadow
{
    public Shadow(string value)
    {
        this.value = value;
    }

    public string value { get; set; }
}

Without the qualifier that statement would be value = value; — legal C# that silently assigns the parameter to itself, which is exactly the class of bug this library exists to rule out.

T is invariant, so widening (object ← string, long ← int) is rejected: C# generic constraints can't express "implicitly convertible to", and a looser rule would let the mismatch it exists to catch slip through. Use AddStatement for those.

Reference paths

A reference need not be a simple name. Member and Item build one reference out of another, so this.a.b and arr[i] can be assigned to as well:

var widget = NamespaceBuilder.Get("MyApp").Class("Widget");
var config = widget.DefineField<Uri>("_config");
var items  = widget.DefineField<string[]>("_items");
var byName = widget.DefineField<Dictionary<string, string>>("_byName");

widget.DefineMethod("Configure")
    .WithParameter<string>("host", out var host)
    .WithParameter<int>("index",   out var index)
    .Assign(config.MemberNamed<string>("Host"), host)
    .Assign(items.Item(index), host)
    .Assign(byName.Item("default"), host);
public void Configure(string host, int index)
{
    _config.Host = host;
    _items[index] = host;
    _byName["default"] = host;
}

The result is an ordinary IReference<T>, so every position that already took one accepts a path with no new overload — assignment on either side, Call receivers and arguments, Return, ThrowIfNull. Paths chain, and when a parameter shadows the leading name only that is qualified — this.config.Host — because everything after the first dot binds in the target's type and can't be shadowed.

Item is typed by the container — IReference<T[]>, IReference<List<T>> and IReference<Dictionary<TKey, TValue>> — so the element type can't be asserted wrongly and a dictionary key of the wrong type is a compile error. Members come in two forms, and the distinction is the same one Assign/AssignLiteral draws: Member(labelProperty) takes the name and the type from the member's own definition, while MemberNamed<string>("Host") asserts both, for a member of a type the generator has no handle to.

This extends references, not expressions. A path names a location; it computes nothing, so there is still no operator and no evaluation to model. One thing it cannot do: ThrowIfNull refuses an element access, because nameof(items[0]) is not legal C# — so the guard would emit source the consumer's build rejects.

Referencing the consumer's types

The typed surface above needs a T. A generator driven by the consumer's code has no T — it holds an ISymbol discovered when the generator runs. So fields and parameters can also be typed by name:

var builder = SourceFile.InNamespace(ns).Class($"{type.Name}Builder");

builder.DefineField("_shipTo", "global::MyApp.Address");

builder.DefineMethod("WithShipTo")
    .WithParameter("shipTo", "global::MyApp.Address")
    .Returns(builder)
    .AddStatement("_shipTo = shipTo;")
    .AddStatement("return this;");

DefineField(name, typeName) hands back a RawFieldBuilder, which is deliberately not an IReference<T> — there is no T to check against, and a phantom type that lied would be worse than none. So Assign and friends can't reach these members, and bodies touching them use AddStatement. Everything structural is still built rather than concatenated: modifiers, attributes, docs, and the declaration itself.

The two string arguments could be transposed, which no compiler can catch — so the name is validated as a C# identifier, and a qualified type name isn't one.

this and construction work here too, so a fluent setter and a Build() need no raw statements:

builder.DefineMethod("Build")
    .Returns("global::MyApp.Order")
    .Return(Value.NewOfType("global::MyApp.Order", customerField, shipToField));
public global::MyApp.Order Build()
{
    return new global::MyApp.Order(_customer, _shipTo);
}

Both are deliberately untyped — a consumer's constructor has no signature the generator can check against, and This() has no T unless a placeholder names it (This<T>() does, and rejoins the typed surface). This() picks up the guards for free: using it from a static member is refused, since there is no this to emit.

Assignment between two such members is AssignRaw, which is checked — not by T, but by comparing both sides' declared type text, the same rule AsCallable validates handles by:

builder.DefineMethod("WithShipTo")
    .WithParameter("shipTo", "global::MyApp.Address", out var shipTo)
    .Returns(builder)
    .AssignRaw(shipToField, shipTo)     // throws if the two declared types differ
    .Return(builder.This());

It's named apart from Assign rather than overloading it. Overloading is provably safe — the two parameter sets are disjoint — but it wrecks the other method's diagnostics: a mismatched typed Assign drops the generic candidate when inference fails, and the raw overload survives to report "cannot convert … to IRawReference", an interface you never mentioned.

Reaching into a discovered type works the same way. MemberRaw reads or writes one of its members, CallRaw forwards a call as a statement, and Invocations.InvokeRaw does it as a value:

decorator.DefineMethod("Greet")
    .WithParameter("name", "string", out var name)
    .Returns("string")
    .Return(Invocations.InvokeRaw(inner, "Greet", name));   // return _inner.Greet(name);

These take their arguments as params rather than in fixed arities, unlike the handle-based Call/Invoke. Those stop at three because each arity needs its own type parameters; with nothing to check there is nothing to bound — and a generator forwarding a discovered method needs whatever arity that method has.

Between them these cover a symbol-driven generator without emitting a single raw statement. Be clear on what that does and doesn't mean: malformed syntax becomes impossible and names come from the builders that declared them, but the checks are by type text and happen when the generator runs. That is strictly less than <T> — it is simply the most available when the type exists only as an ISymbol.

A complete symbol-driven generator is in examples/: it reads the consumer's constructors and emits a fluent builder for each marked type.

Referencing generated types

A generated type has no CLR type, so <T> cannot name it. Two complements close the gap. A builder reference passes the type's builder where a type name goes, so the name is spelled once and only a type actually being built can be referenced:

var order = NamespaceBuilder.Get("MyApp.Models").Class("Order");

var svc = NamespaceBuilder.Get("MyApp.Services").Class("OrderService");
svc.DefineMethod("Save").WithParameter(order, "order");

An [EmitsAs] placeholder is a stand-in type declared in the generator's own assembly; wherever it appears as a type argument, the emitted name is written instead. That lights up the entire typed surface — including IReference<T> and Assign — for generated types:

[EmitsAs("MyApp.Models.Order")]
internal sealed class OrderPh;

var current = owner.DefineProperty<OrderPh>("Current");  // emits MyApp.Models.Order

The placeholder never ships; it exists so the C# compiler holds the definition and every reference to the same name.

Typed calls

Raw statements can also misspell a method — AddStatement("x.SetLabl(n);") parses fine. AsCallable hands back a handle whose asserted signature is validated against the declared parameters (a handle that exists matches its method, and the signature freezes afterwards); Call then type-checks the arguments in your generator:

var widget = NamespaceBuilder.Get("MyApp.Models").Class("Widget");
widget.DefineMethod("SetLabel").WithParameter<string>("label", out _)
    .AsCallable<string>(out var setLabel);

var owner = NamespaceBuilder.Get("MyApp").Class("Owner");
var current = owner.DefineProperty<OrderPh>("Current");
owner.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("label", out var labelParam)
    .Call(current, setLabel, labelParam);
public Owner(string label)
{
    Current.SetLabel(label);
}

Shadowed members are this.-qualified in every position — receivers, arguments, assignment targets and values, returns, and guards.

Returns, literals and guards

A value-returning method carries its return type, so Return is checked:

var calc = NamespaceBuilder.Get("MyApp").Class("Calc");
var total = calc.DefineField<int>("_total");

calc.DefineMethod<int>("Total").Return(total);        // returning a string field: compile error
calc.DefineMethod<bool>("IsEmpty").ReturnLiteral(true);

Compound assignment takes an operator, and ??= has its own pair since it needs a target that can be null:

run.Assign(total, AssignmentOperator.Add, delta)   // _total += delta;
   .AssignLiteral(count, AssignmentOperator.Subtract, 1)
   .AssignIfNullLiteral(name, "unnamed");          // Name ??= "unnamed";

Constants use AssignLiteral / ReturnLiteral, and a null guard emits the classic form — deliberately not ArgumentNullException.ThrowIfNull, which is .NET 6+, because the generated code compiles in the consumer's framework:

user.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("name", out var nameParam)
    .ThrowIfNull(nameParam)
    .Assign(name, nameParam)
    .AssignLiteral(count, 0);
public User(string name)
{
    if (name is null)
        throw new System.ArgumentNullException(nameof(name));
    Name = name;
    Count = 0;
}

Property accessors

Accessors take the same statement API through a scope. A getter gets a Return typed to the property; a setter gets the incoming value as a typed reference:

var backing = widget.DefineField<string>("_name");
widget.DefineProperty<string>("Name")
    .WithGetter(g => g.Return(backing))
    .WithSetter(s => s.ThrowIfNull(s.Value).Assign(backing, s.Value));

value sits in the setter's scope as a real name, so a member also called value qualifies to this.value rather than emitting value = value;.

AsCallableOn goes one further and types the receiver, so pointing a handle at the wrong object is also a compile error. Its calls go through CallOn:

widget.DefineMethod("SetLabel").WithParameter<string>("label", out _)
    .AsCallableOn<WidgetPh, string>(out var setLabel);

owner.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("label", out var labelParam)
    .CallOn(current, setLabel, labelParam);   // current must be an IReference<WidgetPh>

The separate name is deliberate. Sharing Call between the two families made a mismatched receiver report as "cannot convert IMethodOn<T> to IMethod" — the receiver-typed overload fails type inference and is dropped from the candidate list before it can complain, so the error came from the untyped overload that survived, blaming the handle rather than the disagreement. With distinct names there is one candidate, and you get CS0411: the type arguments for method 'CallOn' cannot be inferred — the same diagnostic a mismatched Assign gives.

No registry pairs the two: a placeholder's emitted name and the declaring type's qualified name are the same string, because that is what both become in the generated source. The plain AsCallable family remains for receivers with no placeholder — a type from a shared library, say.

AsCallable refuses a static method rather than emitting instance syntax on one. Static calls have their own family, because their receiver is a type:

run.CallStatic(typeof(Console), nameof(Console.WriteLine), Value.Literal("hi"))
   .CallStatic<Guid>(nameof(Guid.NewGuid))          // non-static declaring type
   .CallStaticRaw("global::Consumer.Log", "Write"); // a type only discovered

Use typeof for a static class — Console, Math, File, Enumerable. C# forbids a static type as a type argument, so CallStatic<Console> doesn't compile, and that covers most static methods there are. The <T> form is for non-static declaring types.

Every form but the raw one routes the type through the same machinery as any other type reference, so it's fully qualified by default and shortens under SimplifyTypeNames with the import added. The method is named by text and unchecked in all of them — the library has no signature to check against, and a handle would look like a check without being one.

Value.Literal(x) is what lets a constant be an argument at all; it's typed, so it fits the checked handles too.

Computed values

A value need not be a name or a constant. Value.New and Invoke produce one from a constructor or a method that returns something:

var file = SourceFile.InNamespace("MyApp");

var widget = file.Class("Widget");
widget.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("label", out _)
    .AsConstructable<WidgetPh, string>(out var newWidget);
widget.DefineMethod<int>("Measure")
    .WithParameter<string>("text", out _)
    .AsFunction<string>(out var measure);

var owner = file.Class("Owner");
var current = owner.DefineProperty<WidgetPh>("Current");
var size = owner.DefineField<int>("_size");

owner.DefineConstructor(AccessModifier.Public)
    .WithParameter<string>("label", out var label)
    .Assign(current, Value.New(newWidget, label))
    .Assign(size, current.Invoke(measure, label));
public Owner(string label)
{
    Current = new MyApp.Widget(label);
    _size = Current.Measure(label);
}

The constructed type is a type reference like any other, so it is fully qualified by default. SimplifyTypeNames() shortens it — except here, where the file declares its own Widget, so the short name would bind to that declaration instead.

AsConstructable is AsCallable for constructors — it validates the asserted signature against the declared parameters and pairs the type argument with the declaring type, so Value.New gives back an IValue<WidgetPh> and assigning it to the wrong property is a compile error. AsFunction is a separate handle family because IMethod<T1…> asserts argument types and so cannot say what a call produces; TResult comes from DefineMethod<T> rather than being asserted, so it can't disagree with the declared return type. AsFunctionOn also checks the receiver, through InvokeOn.

Values compose by nesting — Call(current, take, current.Invoke(measure, name)) — and anything nested inside still gets shadow-qualified.

The line this stops at is deliberate and stated as a rule, because the next feature always looks cheap too: values are produced, never combined. Four producers — a reference, a constant, new T(…), a call's result — and nothing that joins two values. No a + b, no a == b, no conditional. A constructor and a method each have a declaration to check an asserted shape against, which is machinery this library already has; a + b has none, so checking it would mean reimplementing C#'s conversion rules. Branching and arithmetic stay raw text.

Two things that follow from the split, rather than being extra rules: Assign's target and ThrowIfNull still take an IReference<T>, because you cannot assign to a call's result and nameof cannot see one. And a call's receiver is a reference too, so Factory.Create().Configure() isn't expressible — that shape wants a named local, which costs one statement and keeps generated code flat.

Operators

Operators have their own family, because they can never be partial, async, virtual or overridden, and C# fixes their accessibility at public static (CS0558):

var id = file.Struct("OrderId").Readonly().Partial();

id.DefineOperator<bool>(OperatorKind.Equality)
    .WithParameter("left", "MyApp.OrderId")
    .WithParameter("right", "MyApp.OrderId")
    .AsExpressionBody("left.Equals(right)");

id.DefineOperator<bool>(OperatorKind.Inequality)
    .WithParameter("left", "MyApp.OrderId")
    .WithParameter("right", "MyApp.OrderId")
    .AsExpressionBody("!(left == right)");

id.DefineConversion(ConversionKind.Explicit, "int")
    .WithParameter("value", "MyApp.OrderId", out var value)
    .ReturnRaw(value.MemberRaw("Value"));

Two modifiers are available. Unsafe(), and Checked() for the C# 11 checked form:

id.DefineOperator(OperatorKind.Plus, "MyApp.OrderId")          // the unchecked form
    .WithParameter("left", "MyApp.OrderId")
    .WithParameter("right", "MyApp.OrderId")
    .AsExpressionBody("new MyApp.OrderId(left.Value + right.Value)");

id.DefineOperator(OperatorKind.Plus, "MyApp.OrderId")
    .Checked()                                                  // operator checked +
    .WithParameter("left", "MyApp.OrderId")
    .WithParameter("right", "MyApp.OrderId")
    .AsExpressionBody("new MyApp.OrderId(checked(left.Value + right.Value))");

Four language rules are enforced rather than left to your consumer's compiler. == must be declared with != (CS0216), and likewise </>, <=/>=, true/false. A checked form is only legal on + - * /, ++ and -- — not on unary +, remainder, bitwise, shift or comparison operators (CS9023). A checked conversion must be explicit (CS9024). And a checked form needs its unchecked counterpart alongside it (CS9025). Only the type sees every operator, so all four checks live there, and each refuses to emit rather than handing you a build error in someone else's project.

DefineOperator(kind, typeName) is the form for an operator returning the type being generated, which no type argument can name.

Files, and using directives

NamespaceBuilder.Get(ns).Class(name) gives you a type in a file of its own. To put several types in one file — and to control its usings, namespace style, and formatting — start from a SourceFile:

var file = SourceFile.InNamespace("MyApp").SimplifyTypeNames();

file.Class("Repo").DefineField<List<int>>("_items");
file.Record("Box").WithParameter<List<string>>("Values");

context.AddSource("Storage.g.cs", file.ToSourceText());
using System.Collections.Generic;

namespace MyApp;
public class Repo
{
    private List<int> _items;
}

public record Box(List<string> Values);

Usings, SimplifyTypeNames(), BlockScopedNamespace(), WithIndentation and WithLineEndings live on the file rather than on a type, because they describe a file — two types sharing one cannot disagree about them.

Type references are fully qualified by default, which is always correct. SimplifyTypeNames() shortens them and adds the imports they need. A name offered by two different namespaces stays fully qualified rather than becoming ambiguous, and so does one that any type in the file declares — the check is per file, not per type, which is the only way it can be right when types share one. WithUsing("System.Linq") adds a directive explicitly, which is also how you shorten names inside raw expression strings.

Compile-checked templates

Every escape hatch above is the same shape: a body the compiler never sees. The companion package FluentRoslyn.Templates closes that by inverting it — stop describing the body, and write it:

using FluentRoslyn.Templates;

internal static partial class Templates
{
    [Template]
    public static int Add(int a, int b) => a + b;
}

That is real C# in your generator project: the compiler checks it, IntelliSense completes it, and Rename refactors it. A meta-generator — a source generator that runs on your generator project — lifts it into the calls that reproduce it:

public static MethodBuilder<int> EmitAdd(TypeBuilder target)
    => target.DefineMethod<int>("Add")
        .WithParameter<int>("a")
        .WithParameter<int>("b")
        .AsExpressionBody("a + b");

so your generator just says Templates.EmitAdd(calc); and gets an ordinary MethodBuilder<int> that composes with everything else.

This is legal because a generator project is an ordinary library at its own build time. The netstandard2.0 rule constrains what a generator is — it loads into the compiler process — not what it runs on; and generators not seeing each other's output applies within one compilation, while a meta-generator and its target are two.

What it buys, measured. Rename a and you get error CS0103 with a failed build, in your own project. The string form it replaces fails as CS8785 — a warning, in your consumer's build, which succeeds with the generated code silently missing. Types in the body are bound and emitted fully qualified, so a template using StringBuilder still compiles once it lands in a file with different usings.

The ceiling, stated plainly. Templates today are fixed — no holes for varying parts yet. When holes arrive they will stay unchecked at the seams, because a hole is filled when the generator runs, with an expression naming the consumer's types, which do not exist when the template compiles. "Type safe, almost" is the honest maximum. A runnable three-project chain is in examples/, and the reasoning is in docs/DESIGN-templates.md.

Escape hatches

Statement- and expression-bearing members take raw C# text, parsed into the tree. Malformed fragments are rejected (they don't silently produce broken source). Assignment is the exception — it has a checked form, see Typed references:

  • .AsExpressionBody("a + b"), .AddStatement("return x;")
  • .WithInitializerExpression("new()")
  • .WithConstraint("T", "IComparable<T>")

For anything the fluent API can't yet express, .BuildCompilationUnit() hands back the raw CompilationUnitSyntax to manipulate directly.

Using it in a source generator

private void Execute(SourceProductionContext context, ...)
{
    var program = NamespaceBuilder.Get("MyApp")
        .Class("Program").Static().Partial();

    program.DefineMethod("HelloFrom", AccessModifier.None)
        .WithParameter<string>("name")
        .Static().Partial()
        .AsExpressionBody("""System.Console.WriteLine($"Hi from '{name}'")""");

    context.AddSource("Program.g.cs", program.ToSourceText());
}

A complete, runnable example lives in examples/ — a generator that emits a partial method and a constructor-assigned class (via typed references) entirely through the fluent API, plus the app that consumes them.

What's next

Feature-complete for common generator scenarios and published; the remaining work is long-tail language features and the larger items sketched at the end of the roadmap. See docs/ROADMAP.md for the prioritised list, plus the deliberate design decisions behind things that look like gaps (fully-qualified names, fixed formatting, raw-string escape hatches).

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0-preview.9 78 8/8/2026
0.1.0-preview.8 59 8/7/2026
0.1.0-preview.7 67 8/7/2026
0.1.0-preview.6 64 8/7/2026
0.1.0-preview.5 76 8/6/2026

0.1.0-preview.9

No functional changes to this package; an internal comment record was corrected (REVIEWS.md, R2-15, in the repository). It versions in lockstep with FluentRoslyn, which has substantial validation hardening in this release - install the FluentRoslyn with the same version.

0.1.0-preview.8

No source changes to this package. It shares a version line with FluentRoslyn because its generated code targets FluentRoslyn's exact API surface — a coupling NuGet cannot express, since this package deliberately declares no dependencies. The matching version number is the statement that this pairing was built and verified together, so install the FluentRoslyn with the same version, and treat an upgrade of one as an upgrade of both.

0.1.0-preview.7

No changes to this package, for the second release running. It is identical to 0.1.0-preview.5, which was its first and so far only substantive release; it shares a version line with FluentRoslyn, which does have a change in this release.

0.1.0-preview.6

No changes to this package. It is identical to 0.1.0-preview.5 and there is no reason to upgrade for its own sake; it shares a version line with FluentRoslyn, which does have changes in this release.

0.1.0-preview.5 — first release, alongside FluentRoslyn 0.1.0-preview.5.

Mark a method [Template] and its body becomes real, compiled, refactorable C#. The meta-generator reads your generator project's own syntax trees and lifts each template into the FluentRoslyn calls that reproduce it, so breaking a template is an error in your own build rather than a CS8785 warning in your consumer's — a warning that leaves the build green and the generated file silently missing.

Holes are marked in the template and filled at generation time. Diagnostics are reported under the FRT prefix.

The ceiling, stated rather than discovered later: a template is checked against the types your generator project can see. The consumer's types exist only when the generator runs, so anything a hole reaches into stays unchecked at that seam. "Type safe, almost" is the honest description.

Install into the source-generator project itself, not into the consumer.