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 |