From a9439a83d98bdceb17615b399a4246e7d48ffb10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:26:19 +0000 Subject: [PATCH] Add WriteLines to write multi-line text at the current indent [minor] WriteLine and Write hand their text straight to IndentedTextWriter, which indents only the first line, so every line after an embedded break landed at column 0. WriteLines splits at CRLF, LF and CR, writes each line at the current indent ending with NewLineString, keeps blank lines free of indentation, leaves the last line open when the text has no trailing break, and leaves line breaks inside a verbatim string literal as written. Fixes ktsu-dev/CodeBlocker#179 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5MyZsxEdt96yVWhKn --- CLAUDE.md | 2 +- CodeBlocker.Test/WriteLinesTests.cs | 98 +++++++++++++++++++++++++++++ CodeBlocker/CodeBlocker.cs | 67 ++++++++++++++++++++ README.md | 1 + 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 CodeBlocker.Test/WriteLinesTests.cs 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 |