Skip to content

Latest commit

 

History

History
367 lines (293 loc) · 13.6 KB

File metadata and controls

367 lines (293 loc) · 13.6 KB

CodeWriter

CodeWriter is the structured, allocation-conscious writer used to build generated C# source. Instead of concatenating strings or writing raw text, generators describe what to emit — declarations, statements, scopes — and the writer handles indentation, blank-line separation, generated attributes, and deterministic layout.

This page uses the current best-practice API: bare semantic names (Class, Method, Property), the minimal-parameter overloads with an optional configure callback, and structured statements (Return, MethodCall, Assignment) instead of raw text.

Primitives

Use the raw primitives for low-level text that has no structured equivalent:

writer.Write("partial");              // no trailing line feed
writer.Line("// generated");          // line feed appended
writer.Append("text");                // Write alias
writer.AppendLine("text");            // Line alias
writer.Comment("Explains the next member.");
writer.Indent();                      // increase indentation
writer.NewLine();

Write/Line/Append/AppendLine are the only methods that retain a verb prefix: everything semantic drops it because the receiver is already a writer.

Declarations

Each declaration writer has:

  • a minimal overload taking name/type/accessibility plus an optional configure callback (options => options with { ... }); and
  • a scope form (...Scope) returning a BlockScope for using when you need fine-grained control.
writer.Class(
    "OrderService",
    TypeDeclarationAccessibility.Public,
    options => options with { IsSealed = true, IsPartial = false },
    body =>
    {
        body.Field("_total", TypeIdentity.Create<decimal>().AsTypeReference(), TypeDeclarationAccessibility.Private);

        body.Constructor(
            "OrderService",
            TypeDeclarationAccessibility.Public,
            options => options with
            {
                Parameters = [new("total", TypeIdentity.Create<decimal>().AsTypeReference())],
            },
            constructorBody => constructorBody.Assignment("_total", "total")
        );

        body.Property(
            "Total",
            TypeIdentity.Create<decimal>().AsTypeReference(),
            TypeDeclarationAccessibility.Public
        );
    }
);

The same pattern applies to Struct, RecordClass, RecordStruct, Interface, Enum (+ EnumField), Type (kind-driven), Delegate, AttributeClass, Method/PartialMethod/MethodExpression, Property/PropertyExpression, Indexer, Field, and Operator.

Scope forms

using (writer.ClassScope("OrderService", TypeDeclarationAccessibility.Public))
using (writer.MethodScope("Apply", PurviewTypeLibrary.System.Void, TypeDeclarationAccessibility.Public))
{
    writer.MethodCall("Validate");
}

Scope forms are ideal when a declaration spans multiple calls, loops, or conditional content. The using statement is mandatory — the closing token and indentation are written on dispose, and the DiscardedCodeWriterScopeAnalyzer (PSGFR17) flags scope returns that are dropped.

Statements

Emit executable statements through the structured statement methods rather than raw Line:

writer.MethodCall("Process", "item");                       // Process(item);
writer.AwaitedMethodCall("SaveAsync", "cancellationToken"); // await SaveAsync(cancellationToken);
writer.MethodCallOn("variable", "Process", "item");         // variable.Process(item);
writer.AwaitedMethodCallOn("service", "LoadAsync", "token"); // await service.LoadAsync(token);
writer.Return("value");                                     // return value;
writer.Throw(TypeIdentity.Create<InvalidOperationException>(), "Failed.");  // throw new ...;
writer.Assignment("_total", "value");                       // _total = value;
writer.IfBlock("value is null", body => body.Return("null"));
writer.IfBlock("value is null", body => body.Return("null"))
    .ElseIf("value is 0", body => body.Return("zero"))
    .Else(body => body.Return("value"));
writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process"));

MethodCall/AwaitedMethodCall write a call without a receiver — Process(item); or await SaveAsync(token);. Use MethodCallOn/AwaitedMethodCallOn (or the receiver parameter on the IEnumerable overloads) for a call on a variable, including generic arguments:

writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeReference.Create<string>()]);
// factory.Create<string>(x);

A chained invocation — where the result of each call is the receiver of the next, and a postfix is applied to the final result — is expressed with MethodCallChain/AwaitedMethodCallChain. The chain is written as an expression (no terminating semicolon), so it composes as the value of an Assignment/Return expression callback:

writer.Assignment(
    "var hostKitOptions",
    expression => expression.MethodCallChain(
        "builder.Configuration.GetSection",
        [$"{name}.SectionName"],
        chain => chain.Method("Get", genericArguments: [optionsType]).Postfix(" ?? new()")));
// var hostKitOptions = builder.Configuration.GetSection("x.SectionName").Get<Options>() ?? new();
  • rootMethod may include the receiver (e.g. builder.Configuration.GetSection); each subsequent .Method(...) call implicitly uses the previous result as its receiver.
  • genericArguments provides the <...> type arguments for a segment.
  • Postfix(expression) appends a trailing expression such as ?? new() or !.

A null-conditional receiver — onBuilt?.Invoke(this, builder); — is written with the nullConditional argument on the structured MethodCallOn/AwaitedMethodCallOn overloads:

writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true);
// onBuilt?.Invoke(this, builder);

Conditional statements

IfBlock/IfBlockScope write an if block. ElseIf/ElseIfScope chain an else if block after an if or another else if, and Else/ElseScope close the chain with an else block. The methods return the writer, so branches can be chained fluently:

writer
    .IfBlock("value is null", body => body.Return("null"))
    .ElseIf("value is 0", body => body.Return("zero"))
    .Else(body => body.Return("value"));

Emits:

if (value is null)
{
    return null;
}
else if (value is 0)
{
    return zero;
}
else
{
    return value;
}

IfElse(condition, ifBody, elseBody) is the compact two-branch form. The scope forms IfBlockScope, ElseIfScope, and ElseScope write the header and return the body scope for content that spans multiple calls.

Conditional compilation blocks

HashDefines/HashDefinesScope write a #if/#endif block with both directives at column zero. The body keeps the surrounding indentation — file-level directives and their content stay at column zero, while class members inside the block stay at the same indent as their siblings:

using (writer.HashDefinesScope("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING"))
{
    writer.FileScopedNamespace("Example");
    writer.Enum("Mode", TypeDeclarationAccessibility.Public, fields: [new("Default", 0)]);
}

// Equivalent action form:
writer.HashDefines("NET", body => body.Line("// NET only"));

Emits:

#if !EXCLUDE_PURVIEW_TELEMETRY_LOGGING
namespace Example;
...
#endif

At file level these blocks are self-spacing: a blank line is ensured before the #if and after the #endif, so directive sections remain separated without explicit NewLine() calls.

HashElse() writes the #else directive at column zero between the two bodies:

using (writer.HashDefinesScope("NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE"))
{
    writer.Property("name", TypeIdentity.Create<string>().AsTypeReference(), TypeDeclarationAccessibility.Public,
        options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
    writer.HashElse();
    writer.Property("name", TypeIdentity.Create<string>().MakeNullable(writer), TypeDeclarationAccessibility.Public,
        options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
}

Emits:

#if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE
public string name { get; set; }
#else
public string? name { get; set; }
#endif

EmptyScope() returns a no-op scope so a block can be wrapped only when a guard requires it:

using var scope = wrapInExcludeLoggingGuard
    ? writer.EmptyScope()
    : writer.HashDefinesScope("EXCLUDE_PURVIEW_TELEMETRY_LOGGING");

Pragma warning suppression

PragmaDisable writes a single #pragma warning disable directive at column zero for one or more warning codes. At file level it is self-spacing (blank lines are ensured around the directive):

writer.PragmaDisable("CS8625", "CS0618");
// #pragma warning disable CS8625 CS0618

For a scoped disable that restores the warnings when the scope is disposed, use OpenPragmasScope:

using (writer.OpenPragmasScope("CS0618"))
{
    writer.Line("ObsoleteCall();");
}
// #pragma warning disable CS0618
//     ObsoleteCall();
// #pragma warning restore CS0618

The full header pattern — nullable directive, conditional #nullable enable, and a disabled warning — can be expressed entirely through the structured APIs (the file-level directives are self-spacing, so no explicit NewLine() calls are needed):

writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable);
writer.HashDefines("!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", hashWriter => hashWriter.Line("#nullable enable"));
writer.PragmaDisable("CS8625");
writer.FileScopedNamespace("Purview.Telemetry");

Emits:

// <auto-generated />
// This code was generated by ExampleGenerator (version 1.0.0).
// Changes to this file will be lost when the source generator runs again.

#if !NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE
#nullable enable
#endif

#pragma warning disable CS8625

namespace Purview.Telemetry;

Conditional compilation returns

NetConditionalReturn writes a return for an interpolated string using the best invariant-culture API on each target framework, guarded by #if NET:

writer.Method(
    "Format",
    TypeIdentity.Create<string>().AsTypeReference(),
    TypeDeclarationAccessibility.Public,
    null,
    body => body.NetConditionalReturn("Value: {_value}")
);

Emits:

#if NET
    return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"Value: {_value}");
#else
    return global::System.FormattableString.Invariant($"Value: {_value}");
#endif

Default accessibility

CodeWriter applies a default accessibility for each member kind when a declaration does not specify one. Set the defaults on GenerationSettings (to apply across a generation) or on the writer itself (to override per writer). Each value is null-able, so setting a kind back to null omits the modifier entirely.

Setting Default
DefaultTypeAccessibility Public
DefaultPropertyAccessibility Public
DefaultPropertyGetterAccessibility Public
DefaultPropertySetterAccessibility Public
DefaultFieldAccessibility Private
DefaultMethodAccessibility Public
DefaultConstructorAccessibility Public
DefaultIndexerAccessibility Public
DefaultOperatorAccessibility Public
var writer = generationContext.CreateCodeWriter();
writer.Field("_total", TypeReference.Create<decimal>()); // private int _total; (DefaultFieldAccessibility)
writer.Property("Total", TypeReference.Create<decimal>()); // public decimal Total { get; }

An explicit accessibility always wins over the default:

writer.Property("Total", TypeReference.Create<decimal>(), TypeDeclarationAccessibility.Internal);
// internal decimal Total { get; }

Accessor (getter/setter) defaults are emitted only when they are more restrictive than the property's own accessibility — C# forbids an accessor modifier that is equal to or more permissive than the property (CS0273). With the public defaults, a public property keeps bare { get; set; }:

writer.DefaultPropertySetterAccessibility = TypeDeclarationAccessibility.Private;
writer.Property("Name", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public,
    options => options with { HasSetter = true });
// public string Name { get; private set; }

Guidance

  • Prefer the minimal overloads with a configure callback over constructing *DeclarationOptions values manually — the PreferMinimalCodeWriterOverloadAnalyzer (PSGFR20) flags the verbose form.
  • Prefer structured declarations and statements over raw text — PreferStructuredCodeWriterApiAnalyzer (PSGFR18) and PreferStructuredCodeWriterStatementAnalyzer (PSGFR19) flag raw emission.
  • Prefer IfBlock/ElseIf/Else over generic block methods for conditional content — the PreferStructuredCodeWriterIfBlockAnalyzer (PSGFR23) flags OpenBlockScope/OpenBlock headers that write an if, else if, or else statement, and its code fix rewrites them.
  • Always consume scope-returning methods with using (PSGFR17).
  • Keep every value emitted through the structured API so layout stays deterministic and the analyzers can guide callers back to the best practice.

Samples

The SourceGeneratorFramework.ExampleGenerator reference implementation demonstrates these APIs end-to-end, including the CodeWriterSampleGenerator, which compiles a best-practice sample class for every [GenerateCodeWriterSample] target.