diff --git a/skills/roslyn-analyzers/references/SourceWriter.cs b/skills/roslyn-analyzers/references/SourceWriter.cs
index aaadfca83..2243127c0 100644
--- a/skills/roslyn-analyzers/references/SourceWriter.cs
+++ b/skills/roslyn-analyzers/references/SourceWriter.cs
@@ -12,6 +12,12 @@ namespace Product.Analyzers;
///
/// A utility class for generating consistently indented source code.
///
+///
+/// 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 / for braced blocks
+/// and for indented lines that are not braced (e.g. constructor initializers).
+///
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;
///
/// Initializes a new instance of the class.
@@ -67,6 +74,10 @@ internal sealed class SourceWriter
///
/// Gets or sets the current indentation level.
///
+ ///
+ /// Prefer , , and ,
+ /// which keep indentation balanced, over setting this property directly.
+ ///
public int Indentation
{
get => this.indentation;
@@ -81,46 +92,130 @@ internal sealed class SourceWriter
}
}
+ ///
+ /// Writes a character, indenting first if it starts a line.
+ ///
+ /// The non-newline character to write. Use to end a line.
+ /// This writer, for chaining.
+ /// Thrown when is a carriage return or line feed.
+ 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;
+ }
+
+ ///
+ /// Writes text without ending the line, indenting first if it starts a line.
+ ///
+ /// The C# source to write. Embedded CR, LF, and CRLF newlines start new, indented lines and are normalized to CRLF.
+ /// This writer, for chaining.
+ ///
+ /// Use this to build a line from several fragments, such as a member signature assembled in a loop.
+ ///
+ public SourceWriter Write([StringSyntax("c#-test")] string text)
+ {
+ this.WriteLines(text, disableIndentation: false, endWithNewLine: false);
+ return this;
+ }
+
///
/// Writes a character followed by a newline.
///
- /// The character to write.
- public void WriteLine(char value)
- {
- this.AddIndentation();
- this.builder.Append(value);
- this.builder.Append(NewLine);
- }
+ /// The non-newline character to write. Use to write an empty line.
+ /// This writer, for chaining.
+ /// Thrown when is a carriage return or line feed.
+ public SourceWriter WriteLine(char value) => this.Write(value).WriteLine();
///
/// Writes text followed by a newline.
///
- /// The C# source to write.
+ /// The C# source to write. Each embedded line is indented, and CR, LF, and CRLF newlines are normalized to CRLF.
/// Whether to suppress current indentation.
- public void WriteLine(
+ /// This writer, for chaining.
+ public SourceWriter WriteLine(
[StringSyntax("c#-test")] string text,
bool disableIndentation = false)
{
- bool isFinalLine;
- ReadOnlySpan remainingText = text.AsSpan();
- do
- {
- ReadOnlySpan 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;
}
///
/// Writes a newline.
///
- public void WriteLine() => this.builder.Append(NewLine);
+ /// This writer, for chaining.
+ public SourceWriter WriteLine()
+ {
+ this.builder.Append(NewLine);
+ this.atLineStart = true;
+ return this;
+ }
+
+ ///
+ /// Writes an opening brace on its own line and increases the indentation.
+ ///
+ /// This writer, for chaining.
+ ///
+ /// When called mid-line, the line is ended first, so Write("class C").OpenBlock() produces a brace on the next line.
+ ///
+ public SourceWriter OpenBlock()
+ {
+ if (!this.atLineStart)
+ {
+ this.WriteLine();
+ }
+
+ this.WriteLine('{');
+ this.indentation++;
+ return this;
+ }
+
+ ///
+ /// Decreases the indentation and writes a closing brace on its own line.
+ ///
+ /// Text to write after the brace, such as ";" or ")".
+ /// This writer, for chaining.
+ /// Thrown when no indentation level is open.
+ 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);
+ }
+
+ ///
+ /// Increases the indentation until the returned value is disposed.
+ ///
+ /// A value that restores the previous indentation when disposed.
+ ///
+ ///
+ /// writer.WriteLine("public C()");
+ /// using (writer.Indent())
+ /// {
+ /// writer.WriteLine(": base(1)");
+ /// }
+ ///
+ ///
+ public IndentationScope Indent()
+ {
+ this.indentation++;
+ return new IndentationScope(this, this.indentation);
+ }
///
/// 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 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 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 remainingText = text.AsSpan();
+ do
+ {
+ ReadOnlySpan 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 span)
=> this.builder.Append(span.ToString());
+
+ ///
+ /// Restores a to its previous indentation when disposed.
+ ///
+ internal readonly struct IndentationScope : IDisposable
+ {
+ private readonly SourceWriter writer;
+ private readonly int expectedIndentation;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The writer whose indentation to restore.
+ /// The indentation level this scope established.
+ internal IndentationScope(SourceWriter writer, int expectedIndentation)
+ {
+ this.writer = writer;
+ this.expectedIndentation = expectedIndentation;
+ }
+
+ ///
+ /// Decreases the writer's indentation by one level.
+ ///
+ /// Thrown when blocks opened inside the scope were not closed.
+ 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--;
+ }
+ }
}