Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CodeBlocker.Test/CodeBlockerExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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// <auto-generated />\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// <auto-generated />\n\n",
codeBlocker.ToString());
}

[TestMethod]
public void NullableDirectivesAreWrittenVerbatim()
{
Expand Down
16 changes: 16 additions & 0 deletions CodeBlocker.Test/ScopesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>(() => new RegionScope(codeBlocker, $"a{terminator}b"));

Assert.AreEqual(string.Empty, codeBlocker.ToString());
}

[TestMethod]
public void DirectiveScopeWrapsTheBodyInAConditional()
{
Expand Down
13 changes: 9 additions & 4 deletions CodeBlocker/CodeBlockerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@ public static class CodeBlockerExtensions
/// </summary>
/// <param name="codeBlocker">The <see cref="CodeBlocker"/> to write to.</param>
/// <param name="copyright">
/// An optional copyright line written above the marker, without its <c>//</c> prefix. Pass the
/// same text the consuming repository's file header template uses.
/// An optional copyright notice written above the marker, without its <c>//</c> 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 <c>//</c>.
/// </param>
/// <returns>The same <see cref="CodeBlocker"/>, for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="codeBlocker"/> is <see langword="null"/>.</exception>
public static CodeBlocker WriteAutoGeneratedHeader(this CodeBlocker codeBlocker, string? copyright = null)
{
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("// <auto-generated />");
Expand Down
36 changes: 36 additions & 0 deletions CodeBlocker/LineTerminators.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.CodeBlocker;

/// <summary>
/// The characters the C# compiler ends a line at, for text that has to stay inside a single-line
/// comment or directive.
/// </summary>
internal static class LineTerminators
{
/// <summary>
/// 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.
/// </summary>
private static readonly char[] Characters = ['\n', '\r', '\u0085', '\u2028', '\u2029'];

/// <summary>
/// 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 <c>//</c> or <c>///</c>
/// 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.
/// </summary>
/// <param name="text">The text to split.</param>
/// <returns>The lines, without their terminators. Text with no terminator is a single line.</returns>
internal static string[] Split(string text) =>
text.Replace("\r\n", "\n").Split(Characters);

/// <summary>
/// Reports whether text contains any line terminator the C# compiler recognises.
/// </summary>
/// <param name="text">The text to check.</param>
/// <returns><see langword="true"/> if <paramref name="text"/> would span more than one line.</returns>
internal static bool Contains(string text) => text.IndexOfAny(Characters) >= 0;
}
9 changes: 9 additions & 0 deletions CodeBlocker/Scopes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,21 @@ private static void End(CodeBlocker codeBlocker)
/// </remarks>
/// <param name="codeBlocker">The parent <see cref="CodeBlocker"/>.</param>
/// <param name="name">The region name.</param>
/// <exception cref="ArgumentException">
/// <paramref name="name"/> contains a line terminator, which would leave the rest of the name outside
/// the directive. Nothing is written.
/// </exception>
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}");
}

Expand Down
15 changes: 1 addition & 14 deletions CodeBlocker/Templates/DocComment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ private void WriteElement(CodeBlocker codeBlocker, string tagName, string? attri
/// <param name="text">The element's content.</param>
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}>");
Expand All @@ -220,19 +220,6 @@ private void WriteNamedElement(CodeBlocker codeBlocker, string tagName, string?

private string Escape(string text) => EscapeText ? EscapeContent(text) : text;

/// <summary>
/// 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 <c>///</c> 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.
/// </summary>
/// <param name="text">The text to split.</param>
/// <returns>The text's lines.</returns>
private static string[] SplitLines(string text) =>
text.Replace("\r\n", "\n").Split('\n', '\r', '\u0085', '\u2028', '\u2029');

/// <summary>
/// Escapes the characters that would otherwise be read as markup in element content.
/// </summary>
Expand Down
Loading