diff --git a/CLAUDE.md b/CLAUDE.md
index 4e14d42..5b9a40d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`;
diff --git a/CodeBlocker.Test/WriteLinesTests.cs b/CodeBlocker.Test/WriteLinesTests.cs
new file mode 100644
index 0000000..5509b99
--- /dev/null
+++ b/CodeBlocker.Test/WriteLinesTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace CodeBlocker.Tests;
+
+using ktsu.CodeBlocker;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Pins : 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.
+///
+[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());
+ }
+}
diff --git a/CodeBlocker/CodeBlocker.cs b/CodeBlocker/CodeBlocker.cs
index 64f1338..59006d8 100644
--- a/CodeBlocker/CodeBlocker.cs
+++ b/CodeBlocker/CodeBlocker.cs
@@ -278,6 +278,73 @@ public void Write(string text)
isMidLine = true;
}
+ ///
+ /// Write text that may span several lines, indenting every line at the current level.
+ ///
+ /// The text to write. Writing or an empty string does nothing.
+ ///
+ ///
+ /// and 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.
+ ///
+ ///
+ /// The fragment is split at \r\n, \n and \r, and each line is ended with
+ /// , so the output never mixes line endings. A line break inside a
+ /// verbatim string literal (@"...", $@"...") 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.
+ ///
+ ///
+ /// When the fragment ends in a line break, so does the output. When it does not, its last line is
+ /// left open, exactly as leaves it.
+ ///
+ ///
+ 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..]);
+ }
+
+ ///
+ /// Writes one line and ends it, keeping a blank line free of indentation.
+ ///
+ /// The line, without a terminator.
+ private void WriteWholeLine(string line)
+ {
+ if (line.Length == 0)
+ {
+ NewLine();
+ }
+ else
+ {
+ WriteLine(line);
+ }
+ }
+
///
/// Increase the indentation level.
///
diff --git a/README.md b/README.md
index 999786d..73a32cb 100644
--- a/README.md
+++ b/README.md
@@ -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 |