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])}"); @@ -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. ///