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--; + } + } }