Add block-aware indentation to the Roslyn source writer 🤖🤖🤖 (#4329)

* Add block-aware automatic indentation to source generators

* Reject newline characters in SourceWriter fragment APIs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Normalize standalone CR newlines in SourceWriter strings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Andrew Arnott
2026-10-07 16:58:36 +11:00
committed by GitHub
co-authored by Copilot
parent 0052323536
commit 21c42ab3c0
@@ -12,6 +12,12 @@ namespace Product.Analyzers;
/// <summary>
/// A utility class for generating consistently indented source code.
/// </summary>
/// <remarks>
/// Indentation is applied automatically at the start of each non-empty line,
/// so callers should never embed leading whitespace in the text they write.
/// Use <see cref="OpenBlock"/>/<see cref="CloseBlock"/> for braced blocks
/// and <see cref="Indent"/> for indented lines that are not braced (e.g. constructor initializers).
/// </remarks>
internal sealed class SourceWriter
{
// Fixed newlines make generated output deterministic across platforms.
@@ -19,6 +25,7 @@ internal sealed class SourceWriter
private readonly StringBuilder builder = new();
private int indentation;
private bool atLineStart = true;
/// <summary>
/// Initializes a new instance of the <see cref="SourceWriter"/> class.
@@ -67,6 +74,10 @@ internal sealed class SourceWriter
/// <summary>
/// Gets or sets the current indentation level.
/// </summary>
/// <remarks>
/// Prefer <see cref="OpenBlock"/>, <see cref="CloseBlock"/>, and <see cref="Indent"/>,
/// which keep indentation balanced, over setting this property directly.
/// </remarks>
public int Indentation
{
get => this.indentation;
@@ -81,46 +92,130 @@ internal sealed class SourceWriter
}
}
/// <summary>
/// Writes a character, indenting first if it starts a line.
/// </summary>
/// <param name="value">The non-newline character to write. Use <see cref="WriteLine()"/> to end a line.</param>
/// <returns>This writer, for chaining.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="value"/> is a carriage return or line feed.</exception>
public SourceWriter Write(char value)
{
if (value is '\r' or '\n')
{
throw new ArgumentException("Use WriteLine() to write a newline.", nameof(value));
}
this.AddIndentationIfAtLineStart();
this.builder.Append(value);
return this;
}
/// <summary>
/// Writes text without ending the line, indenting first if it starts a line.
/// </summary>
/// <param name="text">The C# source to write. Embedded CR, LF, and CRLF newlines start new, indented lines and are normalized to CRLF.</param>
/// <returns>This writer, for chaining.</returns>
/// <remarks>
/// Use this to build a line from several fragments, such as a member signature assembled in a loop.
/// </remarks>
public SourceWriter Write([StringSyntax("c#-test")] string text)
{
this.WriteLines(text, disableIndentation: false, endWithNewLine: false);
return this;
}
/// <summary>
/// Writes a character followed by a newline.
/// </summary>
/// <param name="value">The character to write.</param>
public void WriteLine(char value)
{
this.AddIndentation();
this.builder.Append(value);
this.builder.Append(NewLine);
}
/// <param name="value">The non-newline character to write. Use <see cref="WriteLine()"/> to write an empty line.</param>
/// <returns>This writer, for chaining.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="value"/> is a carriage return or line feed.</exception>
public SourceWriter WriteLine(char value) => this.Write(value).WriteLine();
/// <summary>
/// Writes text followed by a newline.
/// </summary>
/// <param name="text">The C# source to write.</param>
/// <param name="text">The C# source to write. Each embedded line is indented, and CR, LF, and CRLF newlines are normalized to CRLF.</param>
/// <param name="disableIndentation">Whether to suppress current indentation.</param>
public void WriteLine(
/// <returns>This writer, for chaining.</returns>
public SourceWriter WriteLine(
[StringSyntax("c#-test")] string text,
bool disableIndentation = false)
{
bool isFinalLine;
ReadOnlySpan<char> remainingText = text.AsSpan();
do
{
ReadOnlySpan<char> nextLine = GetNextLine(ref remainingText, out isFinalLine);
if (!disableIndentation && this.indentation > 0)
{
this.AddIndentation();
}
this.AppendSpan(nextLine);
this.builder.Append(NewLine);
}
while (!isFinalLine);
this.WriteLines(text, disableIndentation, endWithNewLine: true);
return this;
}
/// <summary>
/// Writes a newline.
/// </summary>
public void WriteLine() => this.builder.Append(NewLine);
/// <returns>This writer, for chaining.</returns>
public SourceWriter WriteLine()
{
this.builder.Append(NewLine);
this.atLineStart = true;
return this;
}
/// <summary>
/// Writes an opening brace on its own line and increases the indentation.
/// </summary>
/// <returns>This writer, for chaining.</returns>
/// <remarks>
/// When called mid-line, the line is ended first, so <c>Write("class C").OpenBlock()</c> produces a brace on the next line.
/// </remarks>
public SourceWriter OpenBlock()
{
if (!this.atLineStart)
{
this.WriteLine();
}
this.WriteLine('{');
this.indentation++;
return this;
}
/// <summary>
/// Decreases the indentation and writes a closing brace on its own line.
/// </summary>
/// <param name="suffix">Text to write after the brace, such as <c>";"</c> or <c>")"</c>.</param>
/// <returns>This writer, for chaining.</returns>
/// <exception cref="InvalidOperationException">Thrown when no indentation level is open.</exception>
public SourceWriter CloseBlock(string suffix = "")
{
if (this.indentation == 0)
{
throw new InvalidOperationException("No open block is available to close.");
}
if (!this.atLineStart)
{
this.WriteLine();
}
this.indentation--;
return this.Write('}').WriteLine(suffix);
}
/// <summary>
/// Increases the indentation until the returned value is disposed.
/// </summary>
/// <returns>A value that restores the previous indentation when disposed.</returns>
/// <example>
/// <code>
/// writer.WriteLine("public C()");
/// using (writer.Indent())
/// {
/// writer.WriteLine(": base(1)");
/// }
/// </code>
/// </example>
public IndentationScope Indent()
{
this.indentation++;
return new IndentationScope(this, this.indentation);
}
/// <summary>
/// Returns the generated source text.
@@ -149,7 +244,7 @@ internal sealed class SourceWriter
return default;
}
int lineLength = remainingText.IndexOf('\n');
int lineLength = remainingText.IndexOfAny('\r', '\n');
ReadOnlySpan<char> rest;
if (lineLength == -1)
{
@@ -159,23 +254,97 @@ internal sealed class SourceWriter
}
else
{
rest = remainingText[(lineLength + 1)..];
int newlineLength = remainingText[lineLength] == '\r' &&
lineLength + 1 < remainingText.Length &&
remainingText[lineLength + 1] == '\n' ? 2 : 1;
rest = remainingText[(lineLength + newlineLength)..];
isFinalLine = false;
}
if ((uint)lineLength > 0 && remainingText[lineLength - 1] == '\r')
{
lineLength--;
}
ReadOnlySpan<char> next = remainingText[..lineLength];
remainingText = rest;
return next;
}
private void AddIndentation()
=> this.builder.Append(this.IndentationChar, this.CharsPerIndentation * this.indentation);
private void WriteLines(string text, bool disableIndentation, bool endWithNewLine)
{
bool isFinalLine;
ReadOnlySpan<char> remainingText = text.AsSpan();
do
{
ReadOnlySpan<char> nextLine = GetNextLine(ref remainingText, out isFinalLine);
// Empty lines get no indentation, so the output has no trailing whitespace.
if (!nextLine.IsEmpty)
{
if (disableIndentation)
{
this.atLineStart = false;
}
else
{
this.AddIndentationIfAtLineStart();
}
this.AppendSpan(nextLine);
}
if (!isFinalLine || endWithNewLine)
{
this.WriteLine();
}
}
while (!isFinalLine);
}
private void AddIndentationIfAtLineStart()
{
if (this.atLineStart)
{
this.builder.Append(this.IndentationChar, this.CharsPerIndentation * this.indentation);
this.atLineStart = false;
}
}
private void AppendSpan(ReadOnlySpan<char> span)
=> this.builder.Append(span.ToString());
/// <summary>
/// Restores a <see cref="SourceWriter"/> to its previous indentation when disposed.
/// </summary>
internal readonly struct IndentationScope : IDisposable
{
private readonly SourceWriter writer;
private readonly int expectedIndentation;
/// <summary>
/// Initializes a new instance of the <see cref="IndentationScope"/> struct.
/// </summary>
/// <param name="writer">The writer whose indentation to restore.</param>
/// <param name="expectedIndentation">The indentation level this scope established.</param>
internal IndentationScope(SourceWriter writer, int expectedIndentation)
{
this.writer = writer;
this.expectedIndentation = expectedIndentation;
}
/// <summary>
/// Decreases the writer's indentation by one level.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when blocks opened inside the scope were not closed.</exception>
public void Dispose()
{
if (this.writer is null)
{
return;
}
if (this.writer.indentation != this.expectedIndentation)
{
throw new InvalidOperationException($"Indentation level expected to be {this.expectedIndentation} but is {this.writer.indentation}. Close inner blocks before disposing the indentation scope.");
}
this.writer.indentation--;
}
}
}