Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The library is built around these types:
caller's to dispose, its `NewLine` is never changed (the terminator lives on a private
forwarding writer), and `IsBuffered`/`ToString()` only work over a `StringWriter`
- Indentation control via `Indent()`, `Outdent()`, and `CurrentIndent` property
- Output methods: `Write()`, `WriteLine()`, `NewLine()`
- Output methods: `Write()`, `WriteLine()`, `WriteLines()` (multi-line text, every line indented), `NewLine()`
- Implements `IDisposable` with proper resource cleanup

**Line endings.** `IndentedTextWriter` terminates lines with `Environment.NewLine`;
Expand Down
98 changes: 98 additions & 0 deletions CodeBlocker.Test/WriteLinesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace CodeBlocker.Tests;

using ktsu.CodeBlocker;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Pins <see cref="CodeBlocker.WriteLines"/>: every line of a multi-line fragment lands at the
/// current indent, line endings follow the writer, and a verbatim literal's value is left alone.
/// </summary>
[TestClass]
public sealed class WriteLinesTests
{
private const string Nl = CodeBlocker.DefaultNewLineString;

[TestMethod]
public void IndentsEveryLineOfAMultiLineFragment()
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
using (new Scope(codeBlocker))
{
codeBlocker.WriteLines("int a = 1;\nint b = 2;\n");
codeBlocker.WriteLines("x();\n");
codeBlocker.WriteLine("y();");
}

Assert.AreEqual($"{{{Nl}\tint a = 1;{Nl}\tint b = 2;{Nl}\tx();{Nl}\ty();{Nl}}}{Nl}", codeBlocker.ToString());
}

[TestMethod]
public void NormalisesCrLfAndCrToTheWritersTerminator()
{
using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf);
codeBlocker.Indent();
codeBlocker.WriteLines("a();\r\nb();\rc();\r\n");

Assert.AreEqual("\ta();\n\tb();\n\tc();\n", codeBlocker.ToString());
}

[TestMethod]
public void LeavesAVerbatimLiteralsLinesAsWritten()
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
codeBlocker.Indent();
codeBlocker.WriteLines("string s = @\"first\r\nsecond\";\nf();\n");

Assert.AreEqual($"\tstring s = @\"first\r\nsecond\";{Nl}\tf();{Nl}", codeBlocker.ToString());
}

[TestMethod]
public void KeepsBlankLinesFreeOfIndentation()
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
codeBlocker.Indent();
codeBlocker.WriteLines("a();\n\nb();\n");

Assert.AreEqual($"\ta();{Nl}{Nl}\tb();{Nl}", codeBlocker.ToString());
}

[TestMethod]
public void LeavesTheLastLineOpenWhenTheFragmentHasNoTrailingBreak()
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
codeBlocker.Indent();
codeBlocker.WriteLines("a();\nb(");
codeBlocker.Write("1);");
codeBlocker.NewLine();
codeBlocker.WriteLine("c();");

Assert.AreEqual($"\ta();{Nl}\tb(1);{Nl}\tc();{Nl}", codeBlocker.ToString());
}

[TestMethod]
public void EndsALineAlreadyUnderWayAtTheFirstBreak()
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
codeBlocker.Indent();
codeBlocker.Write("int a = ");
codeBlocker.WriteLines("1;\nint b = 2;\n");

Assert.AreEqual($"\tint a = 1;{Nl}\tint b = 2;{Nl}", codeBlocker.ToString());
}

[TestMethod]
[DataRow(null)]
[DataRow("")]
public void WritesNothingForNullOrEmpty(string? fragment)
{
using CodeBlocker codeBlocker = CodeBlocker.Create();
codeBlocker.Indent();
codeBlocker.WriteLines(fragment!);
codeBlocker.NewLine();
codeBlocker.WriteLine("a();");

Assert.AreEqual($"{Nl}\ta();{Nl}", codeBlocker.ToString());
}
}
67 changes: 67 additions & 0 deletions CodeBlocker/CodeBlocker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,73 @@ public void Write(string text)
isMidLine = true;
}

/// <summary>
/// Write text that may span several lines, indenting every line at the current level.
/// </summary>
/// <param name="fragment">The text to write. Writing <see langword="null"/> or an empty string does nothing.</param>
/// <remarks>
/// <para>
/// <see cref="WriteLine(string)"/> and <see cref="Write"/> indent only the first line of what they
/// are given, so a snippet from elsewhere (a doc block, a user-supplied fragment, another
/// generator's output) would land with every line after the first at column 0. This writes each
/// line separately instead, so every one picks up the current indentation.
/// </para>
/// <para>
/// The fragment is split at <c>\r\n</c>, <c>\n</c> and <c>\r</c>, and each line is ended with
/// <see cref="NewLineString"/>, so the output never mixes line endings. A line break inside a
/// verbatim string literal (<c>@"..."</c>, <c>$@"..."</c>) is part of the string's value rather
/// than the code's layout, so it is written exactly as given and the literal's continuation lines
/// get no indentation.
/// </para>
/// <para>
/// When the fragment ends in a line break, so does the output. When it does not, its last line is
/// left open, exactly as <see cref="Write"/> leaves it.
/// </para>
/// </remarks>
public void WriteLines(string fragment)
{
if (string.IsNullOrEmpty(fragment))
{
return;
}

bool[] inVerbatimText = Templates.VerbatimStringScanner.FindVerbatimText(fragment);
int lineStart = 0;
int position = 0;
while (position < fragment.Length)
{
char c = fragment[position];
if ((c != '\n' && c != '\r') || inVerbatimText[position])
{
position++;
continue;
}

WriteWholeLine(fragment[lineStart..position]);
bool isCrLf = c == '\r' && position + 1 < fragment.Length && fragment[position + 1] == '\n';
position += isCrLf ? 2 : 1;
lineStart = position;
}

Write(fragment[lineStart..]);
}

/// <summary>
/// Writes one line and ends it, keeping a blank line free of indentation.
/// </summary>
/// <param name="line">The line, without a terminator.</param>
private void WriteWholeLine(string line)
{
if (line.Length == 0)
{
NewLine();
}
else
{
WriteLine(line);
}
}

/// <summary>
/// Increase the indentation level.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ The main class for building indented code blocks.
| `WriteLine()` | `void` | Writes an empty line with current indentation |
| `Write(string text)` | `void` | Writes text without adding a new line |
| `NewLine()` | `void` | Writes an empty line without indentation |
| `WriteLines(string fragment)` | `void` | Writes text that may span several lines, indenting every line at the current level and ending each with `NewLineString`. A line break inside a verbatim string literal is left as written |
| `Indent()` | `void` | Increases the indent level |
| `Outdent()` | `void` | Decreases the indent level |
| `ToString()` | `string` | Returns the generated code as a string |
Expand Down
Loading