diff --git a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs
index 2aac3dd..0a14c3d 100644
--- a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs
+++ b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs
@@ -35,6 +35,36 @@ public void AutoGeneratedHeaderPutsTheCopyrightAboveTheMarker()
codeBlocker.ToString());
}
+ [TestMethod]
+ [DataRow("\n", DisplayName = "LF")]
+ [DataRow("\r\n", DisplayName = "CRLF")]
+ [DataRow("\r", DisplayName = "CR")]
+ [DataRow("\u0085", DisplayName = "NEL")]
+ [DataRow("\u2028", DisplayName = "LINE SEPARATOR")]
+ [DataRow("\u2029", DisplayName = "PARAGRAPH SEPARATOR")]
+ public void AutoGeneratedHeaderCommentsEveryLineOfAMultiLineCopyright(string terminator)
+ {
+ using CodeBlocker codeBlocker = Create();
+
+ codeBlocker.WriteAutoGeneratedHeader($"Copyright (c) 2026 Acme Corp.{terminator}All rights reserved.");
+
+ Assert.AreEqual(
+ "// Copyright (c) 2026 Acme Corp.\n// All rights reserved.\n// \n\n",
+ codeBlocker.ToString());
+ }
+
+ [TestMethod]
+ public void AutoGeneratedHeaderWritesABlankCopyrightLineAsABareComment()
+ {
+ using CodeBlocker codeBlocker = Create();
+
+ codeBlocker.WriteAutoGeneratedHeader("SPDX-License-Identifier: MIT\n\nCopyright (c) 2026 Acme Corp.");
+
+ Assert.AreEqual(
+ "// SPDX-License-Identifier: MIT\n//\n// Copyright (c) 2026 Acme Corp.\n// \n\n",
+ codeBlocker.ToString());
+ }
+
[TestMethod]
public void NullableDirectivesAreWrittenVerbatim()
{
diff --git a/CodeBlocker.Test/ScopesTests.cs b/CodeBlocker.Test/ScopesTests.cs
index 86716eb..701100f 100644
--- a/CodeBlocker.Test/ScopesTests.cs
+++ b/CodeBlocker.Test/ScopesTests.cs
@@ -90,6 +90,22 @@ public void RegionScopeWithoutANameOmitsTheTrailingSpace()
Assert.AreEqual("#region\nint x;\n#endregion\n", codeBlocker.ToString());
}
+ [TestMethod]
+ [DataRow("\n", DisplayName = "LF")]
+ [DataRow("\r\n", DisplayName = "CRLF")]
+ [DataRow("\r", DisplayName = "CR")]
+ [DataRow("\u0085", DisplayName = "NEL")]
+ [DataRow("\u2028", DisplayName = "LINE SEPARATOR")]
+ [DataRow("\u2029", DisplayName = "PARAGRAPH SEPARATOR")]
+ public void RegionScopeRejectsANameThatSpansLinesBeforeWriting(string terminator)
+ {
+ using CodeBlocker codeBlocker = Create();
+
+ Assert.ThrowsExactly(() => new RegionScope(codeBlocker, $"a{terminator}b"));
+
+ Assert.AreEqual(string.Empty, codeBlocker.ToString());
+ }
+
[TestMethod]
public void DirectiveScopeWrapsTheBodyInAConditional()
{
diff --git a/CodeBlocker/CodeBlockerExtensions.cs b/CodeBlocker/CodeBlockerExtensions.cs
index dc387c6..00365ef 100644
--- a/CodeBlocker/CodeBlockerExtensions.cs
+++ b/CodeBlocker/CodeBlockerExtensions.cs
@@ -19,8 +19,10 @@ public static class CodeBlockerExtensions
///
/// The to write to.
///
- /// An optional copyright line written above the marker, without its // prefix. Pass the
- /// same text the consuming repository's file header template uses.
+ /// An optional copyright notice written above the marker, without its // prefix. Pass the
+ /// same text the consuming repository's file header template uses. A notice of several lines is
+ /// written as one comment line each, split at every terminator the C# compiler recognises, and an
+ /// empty line becomes a bare //.
///
/// The same , for chaining.
/// is .
@@ -28,9 +30,12 @@ public static CodeBlocker WriteAutoGeneratedHeader(this CodeBlocker codeBlocker,
{
Ensure.NotNull(codeBlocker);
- if (!string.IsNullOrEmpty(copyright))
+ if (copyright is { Length: > 0 })
{
- codeBlocker.WriteLine($"// {copyright}");
+ foreach (string line in LineTerminators.Split(copyright))
+ {
+ codeBlocker.WriteLine(line.Length == 0 ? "//" : $"// {line}");
+ }
}
codeBlocker.WriteLine("// ");
diff --git a/CodeBlocker/LineTerminators.cs b/CodeBlocker/LineTerminators.cs
new file mode 100644
index 0000000..d7b8fb3
--- /dev/null
+++ b/CodeBlocker/LineTerminators.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker;
+
+///
+/// The characters the C# compiler ends a line at, for text that has to stay inside a single-line
+/// comment or directive.
+///
+internal static class LineTerminators
+{
+ ///
+ /// Every line terminator the C# compiler recognises: LF, CR, NEL (U+0085), LINE SEPARATOR
+ /// (U+2028) and PARAGRAPH SEPARATOR (U+2029). CRLF is a pair of these.
+ ///
+ private static readonly char[] Characters = ['\n', '\r', '\u0085', '\u2028', '\u2029'];
+
+ ///
+ /// Splits text into lines at every terminator the C# compiler recognises: either platform's
+ /// line ending, so that a caller's verbatim or raw string literal lays out the same way
+ /// whichever platform the source file was written on, and also NEL (U+0085), LINE SEPARATOR
+ /// (U+2028) and PARAGRAPH SEPARATOR (U+2029). The compiler ends a // or ///
+ /// comment at any of those, so left inside a line they would push the rest of the text out of
+ /// the comment and into the code.
+ ///
+ /// The text to split.
+ /// The lines, without their terminators. Text with no terminator is a single line.
+ internal static string[] Split(string text) =>
+ text.Replace("\r\n", "\n").Split(Characters);
+
+ ///
+ /// Reports whether text contains any line terminator the C# compiler recognises.
+ ///
+ /// The text to check.
+ /// if would span more than one line.
+ internal static bool Contains(string text) => text.IndexOfAny(Characters) >= 0;
+}
diff --git a/CodeBlocker/Scopes.cs b/CodeBlocker/Scopes.cs
index e293b07..bbbfb8d 100644
--- a/CodeBlocker/Scopes.cs
+++ b/CodeBlocker/Scopes.cs
@@ -105,12 +105,21 @@ private static void End(CodeBlocker codeBlocker)
///
/// The parent .
/// The region name.
+///
+/// contains a line terminator, which would leave the rest of the name outside
+/// the directive. Nothing is written.
+///
public class RegionScope(CodeBlocker codeBlocker, string name)
: ScopedAction(onOpen: () => Begin(codeBlocker, name), onClose: () => End(codeBlocker))
{
private static void Begin(CodeBlocker codeBlocker, string name)
{
Ensure.NotNull(codeBlocker);
+ if (name is not null && LineTerminators.Contains(name))
+ {
+ throw new ArgumentException("A region name must fit on the #region line.", nameof(name));
+ }
+
codeBlocker.WriteLine(string.IsNullOrEmpty(name) ? "#region" : $"#region {name}");
}
diff --git a/CodeBlocker/Templates/DocComment.cs b/CodeBlocker/Templates/DocComment.cs
index 48902fa..d82f857 100644
--- a/CodeBlocker/Templates/DocComment.cs
+++ b/CodeBlocker/Templates/DocComment.cs
@@ -202,7 +202,7 @@ private void WriteElement(CodeBlocker codeBlocker, string tagName, string? attri
/// The element's content.
private void WriteNamedElement(CodeBlocker codeBlocker, string tagName, string? attributes, string? text)
{
- string[] lines = SplitLines(text ?? string.Empty);
+ string[] lines = LineTerminators.Split(text ?? string.Empty);
if (lines.Length == 1)
{
codeBlocker.WriteLine($"/// <{tagName}{attributes}>{Escape(lines[0])}{tagName}>");
@@ -220,19 +220,6 @@ private void WriteNamedElement(CodeBlocker codeBlocker, string tagName, string?
private string Escape(string text) => EscapeText ? EscapeContent(text) : text;
- ///
- /// Splits text into lines at every terminator the C# compiler recognises: either platform's
- /// line ending, so that a caller's verbatim or raw string literal lays out the same way
- /// whichever platform the source file was written on, and also NEL (U+0085), LINE SEPARATOR
- /// (U+2028) and PARAGRAPH SEPARATOR (U+2029). The compiler ends a /// comment at any of
- /// those, so left inside a line they would push the rest of the text out of the comment and
- /// into the code.
- ///
- /// The text to split.
- /// The text's lines.
- private static string[] SplitLines(string text) =>
- text.Replace("\r\n", "\n").Split('\n', '\r', '\u0085', '\u2028', '\u2029');
-
///
/// Escapes the characters that would otherwise be read as markup in element content.
///