FluentRoslyn 0.1.0-preview.9
Prefix Reserveddotnet add package FluentRoslyn --version 0.1.0-preview.9
NuGet\Install-Package FluentRoslyn -Version 0.1.0-preview.9
<PackageReference Include="FluentRoslyn" Version="0.1.0-preview.9" />
<PackageVersion Include="FluentRoslyn" Version="0.1.0-preview.9" />
<PackageReference Include="FluentRoslyn" />
paket add FluentRoslyn --version 0.1.0-preview.9
#r "nuget: FluentRoslyn, 0.1.0-preview.9"
#:package FluentRoslyn@0.1.0-preview.9
#addin nuget:?package=FluentRoslyn&version=0.1.0-preview.9&prerelease
#tool nuget:?package=FluentRoslyn&version=0.1.0-preview.9&prerelease
<p align="center"> <img src="https://raw.githubusercontent.com/keybindings/FluentRoslyn/main/assets/readme-banner.png" alt="FluentRoslyn — readable source generators" width="820" /> </p>
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.CSharp4.9.2+ on the consuming side - Output: 4-space indentation,
\nline endings (byte-identical across operating systems)
Status: published as
FluentRoslynon nuget.org, currently0.1.0-preview.9, with the optional companion packageFluentRoslyn.Templatesversioned 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).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- 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 | 58 | 8/7/2026 |
| 0.1.0-preview.7 | 67 | 8/7/2026 |
| 0.1.0-preview.6 | 65 | 8/7/2026 |
| 0.1.0-preview.5 | 78 | 8/6/2026 |
| 0.1.0-preview.4 | 68 | 8/6/2026 |
| 0.1.0-preview.3 | 68 | 8/5/2026 |
| 0.1.0-preview.2 | 63 | 8/2/2026 |
| 0.1.0-preview.1 | 67 | 8/2/2026 |
0.1.0-preview.9
A hardening release: everything a code review of the operator feature found, fixed. What was reviewed, when, and what came of it is recorded in docs/REVIEWS.md in the repository.
Operator validation is now signature-aware. C# matches operator pairs, checked/unchecked twins, and duplicates by signature, not by symbol, and the library now does the same. Each of the following previously emitted source that failed in the consumer's build and is now refused when the generator runs:
Wrong operator arity (CS1534/CS1019/CS1020).
A pairing partner with a different signature: ==(A, A) beside !=(A, int) is refused, matching CS0216.
A checked form without an unchecked twin of the same signature (CS9025); conversion identity spans implicit/explicit, so a checked explicit beside an unchecked implicit is refused as the mixed-direction error it is (CS0557).
Duplicate operator or conversion signatures, including implicit and explicit to the same target (CS0111/CS0557).
Operators on a static class (CS0715).
operator true/false with a non-bool result (CS0215).
An instance member referenced from any static context - a static method or an operator - instead of emitting a bare name that fails with CS0120. If a generator did this it was already emitting broken code; the throw is new, the breakage is not.
Type spellings are canonicalized before signatures compare - int equals System.Int32, spaced and unspaced generics agree - so mixing symbol-derived and hand-written type names does not produce false rejections.
New: records can declare operators and conversions, gaining a brace body (never == or !=, which a record synthesizes - the refusal points at Equals instead). OperatorKind.UnsignedRightShift adds >>>, completing the overloadable set that preview.8's notes wrongly claimed was already complete. PartnerDeclaredElsewhere() waives the pairing and twin requirements per operator, for partial types that legally split a pair across their parts. A member builder's own ToString() now runs the same validation the declaring type runs.
Breaking, deliberately, while the version says preview: ConversionKind no longer defines zero (Implicit is now 1), so default(ConversionKind) throws instead of silently declaring an implicit conversion; an undefined OperatorKind or ConversionKind throws at Define time naming the value, rather than a KeyNotFoundException later. Source-compatible unless you wrote those literally; recompile rather than drop in.
0.1.0-preview.8
Operators and conversions, which the library could not declare at all before: DefineMethod validates its name as a C# identifier, so "operator ==" was rejected before it started. That left generated value objects and strongly-typed ids visibly incomplete — users write a == b, not a.Equals(b).
DefineOperator and DefineConversion come in a typed form (the result type as a type argument, which is what comparison operators want since they all return bool) and a raw-named form, for an operator returning the type being generated, which no type argument can name. Every overloadable operator is covered, plus implicit and explicit conversions.
Modifiers: Unsafe(), and Checked() for the C# 11 checked form — public static A operator checked +(A l, A r). Accessibility and staticness are fixed by the language (CS0558) and so are not offered.
Four language rules are enforced when the type is built, rather than passed to your consumer's compiler as a build error you would never see:
== must be declared with != (CS0216), and likewise the ordering pairs and true/false.
A checked form is legal only on + - * / ++ and -- (CS9023). Not remainder, bitwise, shift or comparison — and not unary +, though binary + is fine, so arity decides.
A checked conversion must be explicit (CS9024).
A checked form requires its unchecked counterpart (CS9025).
Only the type sees every operator, so all four checks live there. This is the same stance the enum builder takes on an out-of-range member value: refuse to emit rather than emit something that will not compile.
Source-compatible and binary-compatible with preview.7 — everything here is additive.
0.1.0-preview.7
A small release: one addition.
ReturnRaw(value) on a value-returning method builder. DefineMethod<bool> gives a Return checked against its type argument, but the raw family — InvokeRaw, MemberRaw, Value.NewOfType — produces untyped values, so a method whose signature is known when the generator is compiled (bool Equals(…), int GetHashCode()) could not forward to something the library cannot see without falling back to Returns("bool") and giving up the type argument. ReturnRaw keeps the typed signature and accepts an unchecked value. Named apart from Return rather than overloading it, so a genuine type mismatch on Return still reports as one.
Source-compatible with preview.6, and binary-compatible too — nothing was restructured this time.
0.1.0-preview.6
Completes the story for generators driven by the consumer's own code. A generator working purely from ISymbols can now emit a whole type — declarations, assignments, guards, calls, member reads, returns and construction — without a single raw statement. The decorator example in the repository does exactly that.
Reaching a discovered member. MemberRaw(name) reads or writes a member of a type the generator only found at generation time, CallRaw(target, name, args) forwards a call as a statement, and Invocations.InvokeRaw does the same as a value. These take their arguments as params rather than in fixed arities: the handle-based Call/Invoke families stop at three because each arity needs its own type parameters, and with nothing to check there is nothing to bound — which is what a forwarding generator needs.
Static calls. CallStatic and InvokeStatic, in one form per place a type name can come from: a type argument, a typeof, a builder reference, and raw text. Use the typeof form for a static class — Console, Math, File, Enumerable — because C# forbids a static type as a type argument, so CallStatic<Console> does not compile. Every form but the raw one routes the type through the same machinery as any other type reference, so it is fully qualified by default and shortens under SimplifyTypeNames with the import added.
Value.Literal(x) lets a constant be a call argument, which nothing could be before — AssignLiteral and ReturnLiteral had closed that gap for assignment and return only. It is typed, so it composes with the checked handles as well as the raw ones.
Properties typed by name. DefineProperty(name, typeName) joins the field and parameter forms added in preview.4; without it a discovered interface could not be implemented at all, since its property types come from the interface. Its accessor bodies are built too, via WithGetter/WithSetter scopes — and the setter's `value` carries the property's declared type text, so assigning it into a backing field still checks that the two agree. ThrowIfNullRaw guards a raw-typed parameter, which the type-argument form could not.
The ceiling, unchanged and worth repeating: everything in the raw tier is checked by declared type text when the generator runs, or not at all. That is strictly less than a type argument. What it does guarantee is that malformed syntax is impossible and that names come from the builders that declared them rather than from string formatting that can drift.
Source-compatible with preview.5; binary-breaking, as previews are permitted to be. Property builders gained a shared base, mirroring the change field builders took in preview.4, so recompile rather than drop in.
0.1.0-preview.5
No changes to this library. It is identical to 0.1.0-preview.4 and there is no reason to upgrade for its own sake.
This version exists so that the companion package FluentRoslyn.Templates — new in this release — shares a version line with it. The two ship from one tag and are versioned in lockstep, so a matching pair is always obvious at a glance. FluentRoslyn.Templates is a meta-generator that runs on your source-generator project: mark a method [Template] and its body is real, compiled C# that gets lifted into the FluentRoslyn calls reproducing it. It is optional, and install it into the generator project rather than the consumer.
0.1.0-preview.4
Extends the typed statement surface at both ends: the locations a value can be written to, and the values that can be written.
Reference paths. Member, MemberNamed and Item build one reference out of another, so this.a.b, arr[i] and map[k] are now assignment targets, call receivers, arguments and return values. A path is an IReference, so every position that already accepted one takes a path with no new overload. When a parameter shadows a path's leading name only that name is qualified, because everything after the first dot binds in the target's type.
Computed values. AsConstructable yields a type-checked new T(args), and a call's result can be used as a value rather than only as a statement. Assign, Return and argument positions now take IValue<T>, which IReference<T> implements — existing calls are unaffected.
Members typed by name, for generators driven by the consumer's own code. A consumer's type is an ISymbol, never a CLR T, so the <T> surface could not declare a field or parameter of one at all. DefineField(name, typeName) and WithParameter(name, typeName) now can; AssignRaw connects two such references; Value.NewOfType constructs a type named by text; and This()/This<T>() make `this` a reference. Together these let a generator working purely from consumer symbols emit without a single raw statement — see the builders example in the repository.
Worth being precise about what that buys. The raw-name forms are checked by declared type text when the generator runs, not by the compiler when it builds; construction is not checked at all. That is strictly less than <T>, and it is the most available when a type exists only as an ISymbol. What it does guarantee is that malformed syntax is impossible and that names come from the builders that declared them.
Source-compatible with preview.3; binary-breaking, as previews are permitted to be. Field builders gained a shared base and several signatures widened from IReference to IValue, so recompile rather than drop in.
Earlier previews
BREAKING in preview.3: a generated file is its own builder. SourceFile.InNamespace("MyApp") owns the using directives, SimplifyTypeNames(), BlockScopedNamespace(), WithIndentation() and WithLineEndings() — those five moved off the type builders, because they describe a file rather than a type. Several types can share one file, and simplification decides ambiguity across all of them, which is the only way that decision can be correct when types share a file. NamespaceBuilder.Get(ns).Class(name) still works and opens a single-type file, so the common path is unchanged. Preview.3 also added Source Link and a symbol package, so stepping into this library lands on the real source rather than decompiled IL, and CI builds are deterministic.
From the first preview — type declarations: classes, structs, records, enums, interfaces, and delegates, nestable to any depth; fields, constructors, properties, methods, and events; generics with constraints, attributes (including target specifiers), base types and interfaces, async, required members, and XML doc comments.
Type-checked statements: properties, fields, and parameters are typed references, so Assign matches both sides at generator compile time; AsCallable/Call check a method call's arguments, and AsCallableOn/CallOn check the receiver too. Also Return, literal values, null guards, and compound assignment. A member shadowed by a parameter is qualified automatically rather than silently binding the parameter.
Generated types: reference a type being generated by its builder, or declare an [EmitsAs] placeholder so the whole typed surface works for types that do not exist as CLR types yet.
Output is 4-space/LF by default so it is byte-identical across operating systems; optional using-directive management shortens type names and adds the imports they need.