diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 49c3cbb..26fb88b 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -8,6 +8,7 @@ on:
- 'tests/**'
- 'benchmarks/**'
- 'tools/**'
+ - 'Light.GuardClauses.SingleFile.cs'
- '*.slnx'
- '*.props'
- 'global.json'
@@ -19,6 +20,7 @@ on:
- 'tests/**'
- 'benchmarks/**'
- 'tools/**'
+ - 'Light.GuardClauses.SingleFile.cs'
- '*.slnx'
- '*.props'
- 'global.json'
@@ -90,6 +92,36 @@ jobs:
path: artifacts/coverage
if-no-files-found: warn
+ verify-single-file:
+ name: Verify generated single file
+
+ runs-on: ubuntu-latest
+ needs: build-and-test
+
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ # The exporter defaults to src/Light.GuardClauses and the committed single file at the repository
+ # root, and it validates that the regenerated file compiles before this job compares it.
+ - name: Regenerate single file
+ run: >
+ dotnet run
+ --project tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj
+ -c Release
+ - name: Verify that the single file is up to date
+ run: |
+ if ! git diff --quiet -- Light.GuardClauses.SingleFile.cs; then
+ echo "::error file=Light.GuardClauses.SingleFile.cs::Light.GuardClauses.SingleFile.cs is out of date. Run the source export tool and commit the regenerated file."
+ git diff -- Light.GuardClauses.SingleFile.cs
+ exit 1
+ fi
+
coverage-comment:
name: Coverage comment
runs-on: ubuntu-latest
diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs
index 8b76a70..1234e8d 100644
--- a/Light.GuardClauses.SingleFile.cs
+++ b/Light.GuardClauses.SingleFile.cs
@@ -40,6 +40,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System.Runtime.Serialization;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading;
using JetBrains.Annotations;
using Light.GuardClauses.Exceptions;
using Light.GuardClauses.ExceptionFactory;
@@ -52,7 +53,7 @@ namespace Light.GuardClauses
///
/// The class provides access to all assertions of Light.GuardClauses.
///
- // ReSharper disable once RedundantTypeDeclarationBody -- required for Source Code Transformation
+ // ReSharper disable once RedundantTypeDeclarationBody -- required for Source Code Transformation
internal static class Check
{
///
@@ -4893,7 +4894,7 @@ public static TimeSpan MustBePositive(this TimeSpan parameter, Func TimeSpan.Zero || parameter == System.Threading.Timeout.InfiniteTimeSpan))
+ if (!(parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan))
{
Throw.MustBePositiveOrInfinite(parameter, parameterName, message);
}
@@ -4917,7 +4918,7 @@ public static TimeSpan MustBePositiveOrInfinite(this TimeSpan parameter, [Caller
[ContractAnnotation("exceptionFactory:null => halt")]
public static TimeSpan MustBePositiveOrInfinite(this TimeSpan parameter, Func exceptionFactory)
{
- if (!(parameter > TimeSpan.Zero || parameter == System.Threading.Timeout.InfiniteTimeSpan))
+ if (!(parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan))
{
Throw.CustomException(exceptionFactory, parameter);
}
@@ -11871,7 +11872,7 @@ internal static class RegularExpressions
///
/// Gets the string that represents the .
///
- // This is an AI-generated regex. I don't have any clue and I find it way to complex to ever understand it.
+ // This is an AI-generated regex. I don't have any clue and I find it way to complex to ever understand it.
public const string EmailRegexText = @"^(?:(?:""(?:(?:[^""\\]|\\.)*)""|[\p{L}\p{N}!#$%&'*+\-/=?^_`{|}~-]+(?:\.[\p{L}\p{N}!#$%&'*+\-/=?^_`{|}~-]+)*)@(?:(?:[A-Za-z0-9](?:[A-Za-z0-9\-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}|(?:\[(?:IPv6:[0-9A-Fa-f:.]+)\])|(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d?\d)){3}))$";
///
/// Gets the default regular expression for email validation.
@@ -12628,7 +12629,7 @@ namespace Light.GuardClauses.ExceptionFactory
///
/// Provides static factory methods that throw default exceptions.
///
- // ReSharper disable once RedundantTypeDeclarationBody - requried for the Source Code Transformation
+ // ReSharper disable once RedundantTypeDeclarationBody - requried for the Source Code Transformation
internal static class Throw
{
///
@@ -14175,7 +14176,7 @@ public static bool Contains(// ReSharper disable once RedundantNullableFlowAttri
///
/// Provides extension methods for the class.
///
- // ReSharper disable once RedundantTypeDeclarationBody -- required for Source Code Transformation
+ // ReSharper disable once RedundantTypeDeclarationBody -- required for Source Code Transformation
internal static partial class StringExtensions
{
}
@@ -14409,7 +14410,7 @@ public static StringBuilder AppendLineIf(// ReSharper disable once RedundantNull
/// specified .
///
/// Thrown when any parameter is null.
- // ReSharper disable RedundantNullableFlowAttribute
+ // ReSharper disable RedundantNullableFlowAttribute
public static StringBuilder AppendExceptionMessages([NotNull][ValidatedNotNull] this StringBuilder stringBuilder, [NotNull][ValidatedNotNull] Exception exception)
// ReSharper restore RedundantNullableFlowAttribute
{
@@ -14434,7 +14435,7 @@ public static StringBuilder AppendExceptionMessages([NotNull][ValidatedNotNull]
/// a single string.
///
/// Thrown when is null.
- // ReSharper disable once RedundantNullableFlowAttribute
+ // ReSharper disable once RedundantNullableFlowAttribute
public static string GetAllExceptionMessages([NotNull][ValidatedNotNull] this Exception exception) => new StringBuilder().AppendExceptionMessages(exception).ToString();
///
/// Checks if the two strings are equal using ordinal sorting rules as well as ignoring the white space
diff --git a/ai-plans/0179-source-export-annotation-cleanup.md b/ai-plans/0179-source-export-annotation-cleanup.md
new file mode 100644
index 0000000..533fd18
--- /dev/null
+++ b/ai-plans/0179-source-export-annotation-cleanup.md
@@ -0,0 +1,27 @@
+# Syntax-Aware Source-Export Annotation Cleanup
+
+## Rationale
+
+The source exporter removes annotation usages with exact text replacement and whole-line classification. These approaches miss attributes in combined lists or with alternative argument syntax, and multiline handling currently depends on formatting performed earlier in the export pipeline.
+
+Replace annotation cleanup with a Roslyn syntax transformation so each configured attribute can be removed without disturbing neighboring attributes or nullable-flow metadata. Provide equivalent cleanup support for `NoEnumeration` so exports that omit bundled JetBrains annotations can remain self-contained and compilable.
+
+## Acceptance Criteria
+
+- [x] Configured annotation removal works for standalone, combined, adjacent, and multiline attribute syntax while preserving unrelated attributes and surrounding comments.
+- [x] All existing annotation-removal options use the syntax-aware cleanup path, including caller-argument expressions that use `nameof`.
+- [x] `RemoveNoEnumeration` is available as an opt-in source-export setting with a default value of `false`.
+- [x] The canonical exporter settings and source-inclusion documentation describe `RemoveNoEnumeration`.
+- [x] Removing `ValidatedNotNull` or `NoEnumeration` preserves adjacent nullable-flow annotations such as `NotNull` and `NotNullWhen`.
+- [x] Automated tests cover the supported attribute layouts and protect the behavior of every annotation-removal option.
+- [x] Generated output configured without bundled JetBrains annotation definitions or their using directive compiles successfully when JetBrains-only usages are configured for removal.
+
+## Technical Details
+
+Perform annotation removal on the merged `CompilationUnitSyntax` already maintained by the exporter rather than serializing and reparsing the source. Derive the set of targeted attribute names from `RemoveContractAnnotations`, `RemoveValidatedNotNull`, `RemoveDoesNotReturn`, `RemoveNotNullWhen`, `RemoveCallerArgumentExpressions`, and the new `RemoveNoEnumeration` option.
+
+Inspect `AttributeListSyntax` nodes and remove only matching `AttributeSyntax` children from mixed lists. Remove the complete attribute list when every child is targeted. Preserve exterior trivia when removing nodes, then normalize whitespace after the syntax transformation so comments and unaffected adjacent lists remain well-formed. Attribute matching should use the rightmost name component and treat the optional `Attribute` suffix equivalently; semantic binding is intentionally unnecessary because excluded attribute definitions may be unresolved during export.
+
+Keep non-syntactic formatting cleanup, such as eliminating unwanted blank lines after XML documentation, separate from annotation removal. `RemoveNoEnumeration` must remain independent from `IncludeJetBrainsAnnotations` and `IncludeJetBrainsAnnotationsUsing` because consumers may exclude the bundled definitions while supplying JetBrains annotations externally.
+
+Include focused cleanup tests for partial and complete list removal, multiple adjacent lists, multiline arguments, preservation of trivia, and `CallerArgumentExpression(nameof(parameter))`. Add an end-to-end generated-file build validation using the configuration from issue #178 plus `RemoveNoEnumeration=true`, and assert that targeted names are absent while `NotNull` and `NotNullWhen` remain.
diff --git a/docs/source-code-inclusion.md b/docs/source-code-inclusion.md
index a14cecb..7ebf2f1 100644
--- a/docs/source-code-inclusion.md
+++ b/docs/source-code-inclusion.md
@@ -48,6 +48,7 @@ Configuration keys are case-insensitive. Nested command-line keys use `:`, for e
| `ChangePublicTypesToInternalTypes` | `true` | Makes exported public types internal |
| `BaseNamespace` | `Light.GuardClauses` | Rewrites the base namespace |
| `RemoveContractAnnotations` | `false` | Removes JetBrains contract annotations from members |
+| `RemoveNoEnumeration` | `false` | Removes JetBrains `NoEnumeration` annotation usages |
| `IncludeJetBrainsAnnotations` / `IncludeJetBrainsAnnotationsUsing` | `true` | Controls the bundled annotation and using directive |
| `IncludeVersionComment` | `true` | Adds the generated version header |
| `RemoveOverloadsWithExceptionFactory` | `false` | Globally removes assertion overloads that accept exception factories |
diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs
new file mode 100644
index 0000000..9e5000a
--- /dev/null
+++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs
@@ -0,0 +1,249 @@
+using System;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Xunit;
+
+namespace Light.GuardClauses.SourceCodeTransformation.Tests;
+
+public static class CleanupStepTests
+{
+ [Fact]
+ public static void RemovesAnnotationsFromStandaloneCombinedAdjacentAndMultilineLists()
+ {
+ const string sourceCode =
+ """
+ class Sample
+ {
+ [ContractAnnotation(
+ "value:null => halt",
+ true)]
+ public void Method(
+ [NotNull, ValidatedNotNull] [NoEnumeration] string value,
+ [NotNullWhen(true)] string other)
+ {
+ }
+ }
+ """;
+ var options = new SourceFileMergeOptions
+ {
+ RemoveContractAnnotations = true,
+ RemoveValidatedNotNull = true,
+ RemoveNoEnumeration = true,
+ };
+
+ var transformedSource = RemoveAnnotations(sourceCode, options);
+
+ transformedSource.Should().NotContain("ContractAnnotation");
+ transformedSource.Should().NotContain("ValidatedNotNull");
+ transformedSource.Should().NotContain("NoEnumeration");
+ transformedSource.Should().Contain("[NotNull] string value");
+ transformedSource.Should().Contain("[NotNullWhen(true)] string other");
+ }
+
+ [Fact]
+ public static void RemovesCompleteAttributeListAndPreservesSurroundingComments()
+ {
+ const string sourceCode =
+ """
+ class Sample
+ {
+ // Before the annotation.
+ [ValidatedNotNull] // After the annotation.
+ [NotNull]
+ public string Value = "";
+ }
+ """;
+
+ var transformedSource = RemoveAnnotations(
+ sourceCode,
+ new () { RemoveValidatedNotNull = true }
+ );
+
+ transformedSource.Should().NotContain("ValidatedNotNull");
+ transformedSource.Should().Contain("// Before the annotation.");
+ transformedSource.Should().Contain("// After the annotation.");
+ transformedSource.Should().Contain("[NotNull]");
+ var transformedSyntaxTree = CSharpSyntaxTree.ParseText(
+ transformedSource,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ transformedSyntaxTree.GetDiagnostics(TestContext.Current.CancellationToken).Should().BeEmpty();
+ }
+
+ [Fact]
+ public static void MatchesRightmostAttributeNameAndOptionalAttributeSuffix()
+ {
+ // The attributes are declared inline so that the source code is valid C#: the qualified usage
+ // binds to the JetBrains namespace, the alias-qualified one to the global namespace.
+ const string sourceCode =
+ """
+ using System;
+
+ namespace JetBrains.Annotations
+ {
+ [AttributeUsage(AttributeTargets.Parameter)]
+ public sealed class NoEnumerationAttribute : Attribute { }
+ }
+
+ [AttributeUsage(AttributeTargets.Parameter)]
+ public sealed class NoEnumerationAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Parameter)]
+ public sealed class NotNullAttribute : Attribute { }
+
+ class Sample
+ {
+ public void Method(
+ [JetBrains.Annotations.NoEnumerationAttribute, global::NoEnumeration, NotNull] string value)
+ {
+ }
+ }
+ """;
+
+ var transformedSource = RemoveAnnotations(
+ sourceCode,
+ new () { RemoveNoEnumeration = true }
+ );
+
+ transformedSource.Should().Contain("public void Method([NotNull] string value)");
+ transformedSource.Should().NotContain("JetBrains.Annotations.NoEnumerationAttribute,");
+ transformedSource.Should().NotContain("global::NoEnumeration");
+ }
+
+ [Fact]
+ public static void RemovesCallerArgumentExpressionUsingNameof()
+ {
+ const string sourceCode =
+ """
+ class Sample
+ {
+ public void Method(
+ string parameter,
+ [CallerArgumentExpression(nameof(parameter))] string? parameterName = null)
+ {
+ }
+ }
+ """;
+
+ var transformedSource = RemoveAnnotations(
+ sourceCode,
+ new () { RemoveCallerArgumentExpressions = true }
+ );
+
+ transformedSource.Should().NotContain("CallerArgumentExpression");
+ transformedSource.Should().Contain("string? parameterName = null");
+ }
+
+ [Theory]
+ [InlineData(AnnotationRemovalOption.ContractAnnotation, "ContractAnnotation(\"value:null => halt\")")]
+ [InlineData(AnnotationRemovalOption.ValidatedNotNull, "ValidatedNotNull")]
+ [InlineData(AnnotationRemovalOption.DoesNotReturn, "DoesNotReturn")]
+ [InlineData(AnnotationRemovalOption.NotNullWhen, "NotNullWhen(true)")]
+ [InlineData(AnnotationRemovalOption.CallerArgumentExpression, "CallerArgumentExpression(\"parameter\")")]
+ [InlineData(AnnotationRemovalOption.NoEnumeration, "NoEnumeration")]
+ public static void EveryAnnotationRemovalOptionUsesSyntaxAwareCleanup(
+ AnnotationRemovalOption option,
+ string annotation
+ )
+ {
+ var sourceCode = $"class Sample {{ public void Method([{annotation}, Keep] string parameter) {{ }} }}";
+
+ var transformedSource = RemoveAnnotations(sourceCode, CreateOptions(option));
+
+ transformedSource.Should().NotContain(annotation);
+ transformedSource.Should().Contain("[Keep] string parameter");
+ }
+
+ [Fact]
+ public static void FormattingCleanupRemovesOnlyBlankLinesAfterXmlComments()
+ {
+ const string sourceCode =
+ """
+ /// Documentation.
+
+ public class Sample
+ {
+
+ }
+ """;
+
+ CleanupStep.CleanupFormatting(sourceCode).ToString().Should().Be(
+ """
+ /// Documentation.
+ public class Sample
+ {
+
+ }
+ """
+ );
+ }
+
+ [Fact]
+ public static void FormattingCleanupAlignsCommentsThatFollowXmlComments()
+ {
+ const string sourceCode =
+ " /// Documentation.\n" +
+ " // ReSharper disable RedundantNullableFlowAttribute\n" +
+ " public static string Method(string parameter) => parameter;\n";
+
+ CleanupStep.CleanupFormatting(sourceCode).ToString().Should().Be(
+ " /// Documentation.\n" +
+ " // ReSharper disable RedundantNullableFlowAttribute\n" +
+ " public static string Method(string parameter) => parameter;\n"
+ );
+ }
+
+ [Fact]
+ public static void FormattingCleanupKeepsCommentsThatAreIndentedLessThanTheXmlCommentAbove()
+ {
+ const string sourceCode =
+ " /// Documentation.\n" +
+ " // A comment that is deliberately outdented.\n";
+
+ CleanupStep.CleanupFormatting(sourceCode).ToString().Should().Be(sourceCode);
+ }
+
+ private static string RemoveAnnotations(string sourceCode, SourceFileMergeOptions options)
+ {
+ var syntaxTree = CSharpSyntaxTree.ParseText(
+ sourceCode,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ syntaxTree.GetDiagnostics(TestContext.Current.CancellationToken).Should().BeEmpty();
+
+ var compilationUnit = (CompilationUnitSyntax) syntaxTree.GetRoot(TestContext.Current.CancellationToken);
+ return CleanupStep.RemoveAnnotations(compilationUnit, options)
+ .NormalizeWhitespace(eol: "\n")
+ .ToFullString();
+ }
+
+ private static SourceFileMergeOptions CreateOptions(AnnotationRemovalOption option) =>
+ option switch
+ {
+ AnnotationRemovalOption.ContractAnnotation =>
+ new () { RemoveContractAnnotations = true },
+ AnnotationRemovalOption.ValidatedNotNull =>
+ new () { RemoveValidatedNotNull = true },
+ AnnotationRemovalOption.DoesNotReturn =>
+ new () { RemoveDoesNotReturn = true },
+ AnnotationRemovalOption.NotNullWhen =>
+ new () { RemoveNotNullWhen = true },
+ AnnotationRemovalOption.CallerArgumentExpression =>
+ new () { RemoveCallerArgumentExpressions = true },
+ AnnotationRemovalOption.NoEnumeration =>
+ new () { RemoveNoEnumeration = true },
+ _ => throw new ArgumentOutOfRangeException(nameof(option)),
+ };
+
+ public enum AnnotationRemovalOption
+ {
+ ContractAnnotation,
+ ValidatedNotNull,
+ DoesNotReturn,
+ NotNullWhen,
+ CallerArgumentExpression,
+ NoEnumeration,
+ }
+}
diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs
index 98e5254..9dc7b33 100644
--- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs
+++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs
@@ -37,6 +37,7 @@ public static void SourceFileMergeOptionsUseRepositoryLayoutDefaults()
Path.Combine(TestEnvironment.RepositoryRoot.FullName, "Light.GuardClauses.SingleFile.cs")
);
options.ValidateGeneratedFileBuild.Should().BeTrue();
+ options.RemoveNoEnumeration.Should().BeFalse();
}
[Fact]
@@ -130,4 +131,33 @@ public static partial class Check
File.ReadAllText(targetFile).Should().Contain("UndefinedType");
}
+
+ [Fact]
+ public static void GeneratedFileWithoutBundledJetBrainsAnnotationsCompilesAfterRemovingTheirUsages()
+ {
+ using var temporaryDirectory = new TemporaryDirectory();
+ var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs");
+
+ Program.Main(
+ [
+ $"SourceFolder={TestEnvironment.SourceDirectory.FullName}",
+ $"TargetFile={targetFile}",
+ "TargetFramework=NetStandard2_0",
+ "IncludeVersionComment=false",
+ "IncludeJetBrainsAnnotations=false",
+ "IncludeJetBrainsAnnotationsUsing=false",
+ "RemoveContractAnnotations=true",
+ "IncludeValidatedNotNullAttribute=false",
+ "RemoveValidatedNotNull=true",
+ "RemoveNoEnumeration=true",
+ ]
+ ).Should().Be(0);
+ var sourceCode = File.ReadAllText(targetFile);
+
+ sourceCode.Should().NotContain("ContractAnnotation");
+ sourceCode.Should().NotContain("ValidatedNotNull");
+ sourceCode.Should().NotContain("NoEnumeration");
+ sourceCode.Should().Contain("[NotNull]");
+ sourceCode.Should().Contain("[NotNullWhen(");
+ }
}
diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs
index ea6834d..36a363e 100644
--- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs
+++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs
@@ -1,38 +1,86 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Light.GuardClauses.SourceCodeTransformation;
public static class CleanupStep
{
- public static Memory Cleanup(string sourceCode, SourceFileMergeOptions options)
+ public static CompilationUnitSyntax RemoveAnnotations(
+ CompilationUnitSyntax compilationUnit,
+ SourceFileMergeOptions options
+ )
{
- if (options.RemoveCallerArgumentExpressions)
- sourceCode = sourceCode.Replace("[CallerArgumentExpression(\"parameter\")] ", string.Empty);
- if (options.RemoveValidatedNotNull)
- sourceCode = sourceCode.Replace("[ValidatedNotNull] ", string.Empty);
- if (options.RemoveNotNullWhen)
+ ArgumentNullException.ThrowIfNull(compilationUnit);
+ ArgumentNullException.ThrowIfNull(options);
+
+ var annotationNames = GetAnnotationNamesToRemove(options);
+ if (annotationNames.Count == 0)
+ {
+ return compilationUnit;
+ }
+
+ var nodesToRemove = new List();
+ foreach (var attributeList in compilationUnit.DescendantNodes().OfType())
{
- sourceCode = sourceCode.Replace("[NotNullWhen(true)] ", string.Empty);
- sourceCode = sourceCode.Replace("[NotNullWhen(false)] ", string.Empty);
+ var matchingAttributes = attributeList.Attributes
+ .Where(
+ attribute =>
+ annotationNames.Contains(
+ GetUnqualifiedAttributeName(attribute.Name)
+ )
+ )
+ .ToArray();
+ if (matchingAttributes.Length == 0)
+ {
+ continue;
+ }
+
+ if (matchingAttributes.Length == attributeList.Attributes.Count)
+ {
+ nodesToRemove.Add(attributeList);
+ }
+ else
+ {
+ nodesToRemove.AddRange(matchingAttributes);
+ }
}
+ return nodesToRemove.Count == 0 ?
+ compilationUnit :
+ compilationUnit.RemoveNodes(
+ nodesToRemove,
+ SyntaxRemoveOptions.KeepExteriorTrivia
+ )!;
+ }
+
+ public static Memory CleanupFormatting(string sourceCode)
+ {
+ ArgumentNullException.ThrowIfNull(sourceCode);
+
var sink = new ArrayTextSink(new char[sourceCode.Length]);
var parser = new LineOfCodeParser(sourceCode);
LineOfCode previousLineOfCode = default;
while (parser.TryGetNext(out var currentLineOfCode))
{
- var isEmptyLineAfterXmlComment = previousLineOfCode.Type == LineOfCodeType.XmlComment && currentLineOfCode.Type == LineOfCodeType.WhiteSpace;
- var isContractAnnotationThatShouldBeRemoved = options.RemoveContractAnnotations && currentLineOfCode.Type == LineOfCodeType.ContractAnnotation;
- var isDoesNotReturnThatShouldBeRemoved = options.RemoveDoesNotReturn && currentLineOfCode.Type == LineOfCodeType.DoesNotReturnAnnotation;
-
- if (isEmptyLineAfterXmlComment)
+ var isEmptyLineAfterXmlComment =
+ previousLineOfCode.Type == LineOfCodeType.XmlComment &&
+ currentLineOfCode.Type == LineOfCodeType.WhiteSpace;
+ var isCommentAfterXmlComment =
+ previousLineOfCode.Type == LineOfCodeType.XmlComment &&
+ currentLineOfCode.Type == LineOfCodeType.Comment;
+
+ if (isCommentAfterXmlComment)
{
- // Break point parking spot
+ AppendReindented(ref sink, currentLineOfCode.Span, GetIndentation(previousLineOfCode.Span));
}
-
- if (!isEmptyLineAfterXmlComment && !isContractAnnotationThatShouldBeRemoved && !isDoesNotReturnThatShouldBeRemoved)
+ else if (!isEmptyLineAfterXmlComment)
+ {
sink.Append(currentLineOfCode.Span);
+ }
previousLineOfCode = currentLineOfCode;
}
@@ -40,6 +88,102 @@ public static Memory Cleanup(string sourceCode, SourceFileMergeOptions opt
return sink.ToMemory();
}
+ private static ReadOnlySpan GetIndentation(ReadOnlySpan line) =>
+ line.Slice(0, line.Length - line.TrimStart().Length);
+
+ ///
+ /// Aligns a single-line comment with the XML documentation comment above it. Removing an attribute list
+ /// merges its XML documentation into the trivia of the following token, and NormalizeWhitespace then
+ /// indents a single-line comment in that same trivia list by one additional character.
+ ///
+ private static void AppendReindented(
+ ref ArrayTextSink sink,
+ ReadOnlySpan line,
+ ReadOnlySpan indentation
+ )
+ {
+ var trimmedLine = line.TrimStart();
+ // The sink is sized after the original source code, thus the line must never grow
+ if (indentation.Length + trimmedLine.Length > line.Length)
+ {
+ sink.Append(line);
+ return;
+ }
+
+ sink.Append(indentation);
+ sink.Append(trimmedLine);
+ }
+
+ private static HashSet GetAnnotationNamesToRemove(SourceFileMergeOptions options)
+ {
+ var annotationNames = new HashSet(StringComparer.Ordinal);
+ if (options.RemoveContractAnnotations)
+ {
+ annotationNames.Add("ContractAnnotation");
+ }
+
+ if (options.RemoveValidatedNotNull)
+ {
+ annotationNames.Add("ValidatedNotNull");
+ }
+
+ if (options.RemoveDoesNotReturn)
+ {
+ annotationNames.Add("DoesNotReturn");
+ }
+
+ if (options.RemoveNotNullWhen)
+ {
+ annotationNames.Add("NotNullWhen");
+ }
+
+ if (options.RemoveCallerArgumentExpressions)
+ {
+ annotationNames.Add("CallerArgumentExpression");
+ }
+
+ if (options.RemoveNoEnumeration)
+ {
+ annotationNames.Add("NoEnumeration");
+ }
+
+ return annotationNames;
+ }
+
+ private static string GetUnqualifiedAttributeName(NameSyntax name)
+ {
+ var simpleName = name switch
+ {
+ QualifiedNameSyntax qualifiedName => qualifiedName.Right,
+ AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name,
+ _ => (SimpleNameSyntax) name,
+ };
+ var identifier = simpleName.Identifier.ValueText;
+ const string attributeSuffix = "Attribute";
+ return identifier.EndsWith(attributeSuffix, StringComparison.Ordinal) ?
+ identifier[..^attributeSuffix.Length] :
+ identifier;
+ }
+
+ public static bool TryGetNextLine(this ReadOnlySpan text, int startIndex, out ReadOnlySpan nextLine)
+ {
+ if (startIndex >= text.Length)
+ {
+ nextLine = default;
+ return false;
+ }
+
+ nextLine = text.Slice(startIndex);
+ var newLineIndex = nextLine.IndexOf('\n');
+ if (newLineIndex == -1)
+ {
+ return true;
+ }
+
+ nextLine = nextLine.Slice(0, newLineIndex + 1);
+ return true;
+ }
+
private ref struct LineOfCodeParser
{
private readonly ReadOnlySpan _sourceCode;
@@ -63,18 +207,23 @@ public bool TryGetNext(out LineOfCode lineOfCode)
var leftTrimmedSpan = currentLineSpan.TrimStart();
if (leftTrimmedSpan.StartsWith("///"))
- lineOfCode = new LineOfCode(LineOfCodeType.XmlComment, currentLineSpan);
- else if (leftTrimmedSpan.StartsWith("[ContractAnnotation("))
- lineOfCode = new LineOfCode(LineOfCodeType.ContractAnnotation, currentLineSpan);
- else if (leftTrimmedSpan.StartsWith("[DoesNotReturn]"))
- lineOfCode = new LineOfCode(LineOfCodeType.DoesNotReturnAnnotation, currentLineSpan);
+ {
+ lineOfCode = new (LineOfCodeType.XmlComment, currentLineSpan);
+ }
+ else if (leftTrimmedSpan.StartsWith("//"))
+ {
+ lineOfCode = new (LineOfCodeType.Comment, currentLineSpan);
+ }
else if (leftTrimmedSpan.IsEmpty)
- lineOfCode = new LineOfCode(LineOfCodeType.WhiteSpace, currentLineSpan);
+ {
+ lineOfCode = new (LineOfCodeType.WhiteSpace, currentLineSpan);
+ }
else
- lineOfCode = new LineOfCode(LineOfCodeType.Other, currentLineSpan);
+ {
+ lineOfCode = new (LineOfCodeType.Other, currentLineSpan);
+ }
return true;
-
}
}
@@ -96,25 +245,7 @@ private enum LineOfCodeType
{
Other,
XmlComment,
+ Comment,
WhiteSpace,
- ContractAnnotation,
- DoesNotReturnAnnotation
- }
-
- public static bool TryGetNextLine(this ReadOnlySpan text, int startIndex, out ReadOnlySpan nextLine)
- {
- if (startIndex >= text.Length)
- {
- nextLine = default;
- return false;
- }
-
- nextLine = text.Slice(startIndex);
- var newLineIndex = nextLine.IndexOf('\n');
- if (newLineIndex == -1)
- return true;
-
- nextLine = nextLine.Slice(0, newLineIndex + 1);
- return true;
}
}
diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs
index f27a2e0..0ec0a8f 100644
--- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs
+++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs
@@ -61,4 +61,6 @@ public SourceFileMergeOptions()
public bool IncludeCallerArgumentExpressionAttribute { get; init; } = true;
public bool RemoveCallerArgumentExpressions { get; init; } = false;
+
+ public bool RemoveNoEnumeration { get; init; } = false;
}
diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs
index 12e8acb..83189bf 100644
--- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs
+++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs
@@ -37,7 +37,7 @@ public static void CreateSingleSourceFile(SourceFileMergeOptions options)
}
stringBuilder.AppendLine(
- $@"License information for Light.GuardClauses
+ $@"License information for Light.GuardClauses
The MIT License (MIT)
Copyright (c) 2016, 2026 Kenny Pflug mailto:kenny.pflug@live.de
@@ -100,7 +100,7 @@ namespace {options.BaseNamespace}.ExceptionFactory
namespace {options.BaseNamespace}.FrameworkExtensions
{{
}}"
- );
+ );
if (options.IncludeJetBrainsAnnotations)
{
stringBuilder.AppendLine().AppendLine(
@@ -657,10 +657,13 @@ node is ClassDeclarationSyntax
);
}
+ targetRoot = CleanupStep.RemoveAnnotations(targetRoot, options);
+ targetRoot = targetRoot.NormalizeWhitespace(eol: "\n");
+
var targetFileContent = targetRoot.ToFullString();
Console.WriteLine("File is cleaned up...");
- targetFileContent = CleanupStep.Cleanup(targetFileContent, options).ToString();
+ targetFileContent = CleanupStep.CleanupFormatting(targetFileContent).ToString();
targetFileContent = targetFileContent.ReplaceLineEndings("\n");
if (!targetFileContent.EndsWith('\n'))
{
diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json
index 412dd8d..8555205 100644
--- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json
+++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json
@@ -17,6 +17,7 @@
"RemoveNotNullWhen": false,
"IncludeCallerArgumentExpressionAttribute": true,
"RemoveCallerArgumentExpressions": false,
+ "RemoveNoEnumeration": false,
"AssertionWhitelist": {
"IsEnabled": false,
"ContainsOnlyDigits": { "Include": true, "IncludeExceptionFactoryOverload": true },