Skip to content

Source writing

Haitch.Roslyn.Writing has two layers: SourceWriter, a small indenting text builder, and a typed scoped writer built on top of it that writes declarations from the models.

SourceWriter builds indented text with a StringBuilder and no LINQ, so it is cheap inside a pipeline.

var writer = new SourceWriter();
writer.WriteLine("namespace App");
using (writer.Block())
{
writer.WriteLine("internal static class Greeter");
using (writer.Block())
{
writer.WriteLine("public static string Hello() => \"hi\";");
}
}
string text = writer.ToString();
SourceText sourceText = writer.ToSourceText(); // UTF-8, SHA-256
MemberBehaviour
WriteLine(string text = "")Writes the text as one or more lines at the current indent. Embedded \n splits lines; blank lines are not indented. Returns the writer.
Write(string text)Writes without ending the line, so one line can be assembled in parts.
Block(open = "{", close = "}")Writes open, indents, and returns a scope; disposing it outdents and writes close.
ToString() / ToSourceText()The accumulated text. Throws InvalidOperationException if a scope recorded an error when it closed; see Guards.

SourceWriterExtensions writes pieces of a file from the models.

MethodWrites
WriteAutoGeneratedHeader()// <auto-generated/>, #nullable enable and a blank line.
WriteNamespace(string? name)A file-scoped namespace, followed by a blank line. Nothing for null.
WriteTypeDeclaration(TypeModel)The type’s partial declaration inside the partial declarations of its containing types, one block per level. Dispose the returned scope to close them all.
WriteMethodSignature(MethodModel)A signature terminated with ;, for partial, abstract or interface members.

WriteTypeDeclaration writes only readonly/ref (needed on every partial struct), partial, the kind, name and type parameters with constraints. Accessibility and static/abstract/sealed are deliberately not echoed onto the partial.

The scoped writer exposes only what is legal at each position, so illegal nesting fails to compile. Every scope is a ref struct and is disposed with using.

writer.File()
├─ Using(namespace)
├─ Namespace(name) ─▶ Type(TypeModel)
└─ Type(TypeModel) (global namespace)
TypeScope
├─ Type(TypeModel) nested partial type
├─ Line(string text = "") raw line at the body indent
├─ Method(MethodModel) ─▶ BodyScope
├─ Field(FieldModel, string? initializer = null)
├─ AutoProperty(PropertyModel)
└─ Property(PropertyModel) ─▶ PropertyScope ─▶ Get() / Set() / Init() ─▶ BodyScope
BodyScope
├─ Line(string text = "")
├─ Block(string header) ─▶ BodyScope
└─ statement scopes (If, ForEach, Try, Switch, ...)

FileScope, NamespaceScope and TypeScope also write attributes and brand-new types. Statement scopes, attributes and new types are covered in Attributes, new types and statements.

var writer = new SourceWriter();
using (var file = writer.File())
{
file.Using("System");
using var ns = file.Namespace("App");
using var type = ns.Type(typeModel);
type.Field(counterField);
using (var body = type.Method(resetMethod))
{
body.Line("_counter = 0;");
using var guard = body.Block("if (Verbose)");
guard.Line("Console.WriteLine(\"reset\");");
}
}

A blank line is inserted automatically between sibling members, types, usings and namespaces. File() writes the header, so you do not call WriteAutoGeneratedHeader yourself.

FileScope.Type and NamespaceScope.Type write the type’s declaration including its containing types and return the innermost TypeScope; disposing it closes every block opened. TypeModel.Namespace is ignored there: the enclosing scope decides the namespace. For the global namespace, call file.Type(...) directly. There is no global-namespace scope.

TypeScope.Type writes a nested partial declaration of that type alone. Its ContainingTypes must be empty or end with the type of the scope it is called on. File-local types throw ArgumentException.

FileScope.Type, NamespaceScope.Type and TypeScope.Type take an optional EquatableArray<TypeRef> baseTypes. Those types are written as a base list on the innermost declaration only, never on its containing types.

System.ComponentModel.INotifyPropertyChanged
using var type = ns.Type(typeModel, baseTypes: [notifyInterface]);

The list is not validated. Another part of the type may already declare a base class or the interface, and the compiler reports that. Check TypeModel.BaseType and AllInterfaces first when it matters, and write only the interfaces the type does not already implement.

TypeScope.Event(EventModel) writes one field-like event line, such as public event global::System.EventHandler? Changed;, with static, abstract, virtual, override and sealed as the model says. It throws ArgumentException for an event with written-out accessors (IsFieldLike is false) and for an explicit interface implementation.

TypeScope.Line(string text = "") writes the text as one or more raw lines at the type body’s indent (embedded newlines are split) and returns the scope. It is for text that no model covers, such as a #pragma. It behaves like SourceWriter.WriteLine at that point: a buffered attribute stays pending and is written above the next member, after the line.

scope.Line("#pragma warning disable CS0067");
scope.Event(changedEvent);
scope.Line("#pragma warning restore CS0067");

BodyScope, IfScope and TryScope Line also default text to "", so Line() writes a blank line.

Method writes the signature and opens a braced body. Abstract and extern methods (including interface members) have no body and throw; use WriteMethodSignature for them, which writes abstract override for an abstract override.

Method and WriteMethodSignature accept only a MethodModel whose MethodKind is Ordinary or ExplicitInterfaceImplementation. Constructors, destructors, operators, conversions and accessors throw ArgumentException; write those by hand.

The writer emits partial when MethodModel.IsPartial is true. That covers both parts, so the implementation part of a partial method you write compiles next to its definition.

  • Field(field, initializer) writes one declaration line, including volatile when FieldModel.IsVolatile is set. A const field takes its value from FieldModel.ConstantValue and rejects an initializer. Instance fields on an interface throw.
  • AutoProperty(property) writes public int X { get; private set; }-style lines from any property shape; accessor bodies and expression bodies become auto accessors. It throws ArgumentException for an abstract property, an explicit interface implementation, a property with no getter, or an instance property in an interface. It also throws for a property that returns by ref, because a ref property cannot be an auto-property.
  • Property(property) writes the header and opens the accessor list. Call Get(), Set() or Init() for each accessor body; each can be opened once, the model must have that accessor, and the previous accessor scope must be disposed first (otherwise InvalidOperationException).
using var property = type.Property(nameProperty);
using (var get = property.Get())
{
get.Line("return _name;");
}
using (var set = property.Set())
{
set.Line("_name = value;");
}

Abstract properties, explicit interface implementations and properties with no accessors throw from Property, as does a ref-returning property that has a set or init accessor. Property writes ref and ref readonly returns from PropertyModel.ReturnRefKind.

Since 0.3.0 the writer detects misuse and reports it instead of emitting wrong code. This is always on, not debug-only.

  • Throws at the call. Writing to a scope while a child opened from it is still open, writing through a stale copy, chaining or opening a switch section out of order, and using a scope after its block closed all throw InvalidOperationException with a message that names the scope. Nothing is written by the failed call.
  • Dispose never throws. A throw from Dispose would mask an exception already in flight. A problem found while closing, such as a try with neither catch nor finally, a switch whose last section can fall through, or an Attribute() with nothing after it, is recorded on the writer. ToString() and ToSourceText() then throw it. After any scope exception, treat the output as unusable, because the unwinding disposals can record further errors.
  • Disposing a copy is safe. A using local forces defensive copies of a ref struct. Disposing a copy after the original, or twice, is a no-op, even when another block has since opened at the same depth. Using a copy after its block closed throws.
  • Disposing closes inner blocks. Disposing a scope closes any blocks still open inside it, innermost first.

The statement-level guards (switch fall-through, try/catch, labels) are described in Attributes, new types and statements.

  • Write all Using calls before opening a namespace. A Using after a namespace is invalid C# (CS1529).

The statement scopes have a few remaining caller errors that the writer cannot catch; they are listed in Attributes, new types and statements.