From 5982c3ec0c98195e46873459edd8c4f5469d43e4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 24 Jul 2026 20:41:34 +0200 Subject: [PATCH 1/4] docs: add AI plan to fix annotation cleanup during source export Signed-off-by: Kenny Pflug --- .../0179-source-export-annotation-cleanup.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ai-plans/0179-source-export-annotation-cleanup.md 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..418abc1 --- /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 + +- [ ] Configured annotation removal works for standalone, combined, adjacent, and multiline attribute syntax while preserving unrelated attributes and surrounding comments. +- [ ] All existing annotation-removal options use the syntax-aware cleanup path, including caller-argument expressions that use `nameof`. +- [ ] `RemoveNoEnumeration` is available as an opt-in source-export setting with a default value of `false`. +- [ ] The canonical exporter settings and source-inclusion documentation describe `RemoveNoEnumeration`. +- [ ] Removing `ValidatedNotNull` or `NoEnumeration` preserves adjacent nullable-flow annotations such as `NotNull` and `NotNullWhen`. +- [ ] Automated tests cover the supported attribute layouts and protect the behavior of every annotation-removal option. +- [ ] 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. From 372ce0570b588fac9120c39f4a60014447ce48ba Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 24 Jul 2026 21:27:28 +0200 Subject: [PATCH 2/4] fix: proper cleanup of annotations Signed-off-by: Kenny Pflug --- .../0179-source-export-annotation-cleanup.md | 14 +- docs/source-code-inclusion.md | 1 + .../CleanupStepTests.cs | 201 ++++++++++++++++++ .../GeneratedFileBuildValidatorTests.cs | 30 +++ .../CleanupStep.cs | 180 ++++++++++++---- .../SourceFileMergeOptions.cs | 2 + .../SourceFileMerger.cs | 9 +- .../settings.json | 1 + 8 files changed, 385 insertions(+), 53 deletions(-) create mode 100644 tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs diff --git a/ai-plans/0179-source-export-annotation-cleanup.md b/ai-plans/0179-source-export-annotation-cleanup.md index 418abc1..533fd18 100644 --- a/ai-plans/0179-source-export-annotation-cleanup.md +++ b/ai-plans/0179-source-export-annotation-cleanup.md @@ -8,13 +8,13 @@ Replace annotation cleanup with a Roslyn syntax transformation so each configure ## Acceptance Criteria -- [ ] Configured annotation removal works for standalone, combined, adjacent, and multiline attribute syntax while preserving unrelated attributes and surrounding comments. -- [ ] All existing annotation-removal options use the syntax-aware cleanup path, including caller-argument expressions that use `nameof`. -- [ ] `RemoveNoEnumeration` is available as an opt-in source-export setting with a default value of `false`. -- [ ] The canonical exporter settings and source-inclusion documentation describe `RemoveNoEnumeration`. -- [ ] Removing `ValidatedNotNull` or `NoEnumeration` preserves adjacent nullable-flow annotations such as `NotNull` and `NotNullWhen`. -- [ ] Automated tests cover the supported attribute layouts and protect the behavior of every annotation-removal option. -- [ ] Generated output configured without bundled JetBrains annotation definitions or their using directive compiles successfully when JetBrains-only usages are configured for removal. +- [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 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..4238ccd --- /dev/null +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs @@ -0,0 +1,201 @@ +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() + { + const string sourceCode = + """ + class Sample + { + public void Method( + [JetBrains.Annotations.NoEnumerationAttribute, Annotations::NoEnumeration, NotNull] string value) + { + } + } + """; + + var transformedSource = RemoveAnnotations( + sourceCode, + new () { RemoveNoEnumeration = true } + ); + + transformedSource.Should().NotContain("NoEnumeration"); + transformedSource.Should().Contain("[NotNull] string value"); + } + + [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 + { + + } + """ + ); + } + + private static string RemoveAnnotations(string sourceCode, SourceFileMergeOptions options) + { + var compilationUnit = (CompilationUnitSyntax) CSharpSyntaxTree.ParseText(sourceCode).GetRoot(); + 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..b41b9c9 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs @@ -1,38 +1,79 @@ 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; + var isEmptyLineAfterXmlComment = + previousLineOfCode.Type == LineOfCodeType.XmlComment && + currentLineOfCode.Type == LineOfCodeType.WhiteSpace; - if (isEmptyLineAfterXmlComment) + if (!isEmptyLineAfterXmlComment) { - // Break point parking spot - } - - if (!isEmptyLineAfterXmlComment && !isContractAnnotationThatShouldBeRemoved && !isDoesNotReturnThatShouldBeRemoved) sink.Append(currentLineOfCode.Span); + } previousLineOfCode = currentLineOfCode; } @@ -40,6 +81,77 @@ public static Memory Cleanup(string sourceCode, SourceFileMergeOptions opt return sink.ToMemory(); } + 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 unqualifiedName => unqualifiedName, + _ => throw new ArgumentOutOfRangeException(nameof(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 +175,19 @@ 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.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; - } } @@ -97,24 +210,5 @@ private enum LineOfCodeType Other, XmlComment, 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 }, From 484fcb794bebede009c9890a134f1aafe1c0d02f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 24 Jul 2026 21:57:40 +0200 Subject: [PATCH 3/4] fix: align comments that follow XML documentation during source export NormalizeWhitespace indents a single-line comment by one additional character when it shares a trivia list with an XML documentation comment. Removing an attribute list merges these two into the same list, but the misalignment also occurred without annotation removal and was present in the committed single file. Align such comments with the XML documentation above them in the line-based formatting cleanup, and regenerate the single file. The regeneration also picks up the "using System.Threading" directive that became stale with MustBePositiveOrInfinite. Furthermore, drop the unreachable throw when determining the unqualified attribute name, make the alias-qualified test source compilable, and pass the test cancellation token when parsing. Signed-off-by: Kenny Pflug Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KVPxLqTavx5hjAFWHe8TMc --- Light.GuardClauses.SingleFile.cs | 17 +++--- .../CleanupStepTests.cs | 56 +++++++++++++++++-- .../CleanupStep.cs | 43 +++++++++++++- 3 files changed, 101 insertions(+), 15 deletions(-) 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/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs index 4238ccd..9e5000a 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/CleanupStepTests.cs @@ -75,12 +75,28 @@ class Sample [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, Annotations::NoEnumeration, NotNull] string value) + [JetBrains.Annotations.NoEnumerationAttribute, global::NoEnumeration, NotNull] string value) { } } @@ -91,8 +107,9 @@ public void Method( new () { RemoveNoEnumeration = true } ); - transformedSource.Should().NotContain("NoEnumeration"); - transformedSource.Should().Contain("[NotNull] string value"); + transformedSource.Should().Contain("public void Method([NotNull] string value)"); + transformedSource.Should().NotContain("JetBrains.Annotations.NoEnumerationAttribute,"); + transformedSource.Should().NotContain("global::NoEnumeration"); } [Fact] @@ -163,9 +180,40 @@ 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 compilationUnit = (CompilationUnitSyntax) CSharpSyntaxTree.ParseText(sourceCode).GetRoot(); + 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(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs index b41b9c9..36a363e 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/CleanupStep.cs @@ -69,8 +69,15 @@ public static Memory CleanupFormatting(string sourceCode) var isEmptyLineAfterXmlComment = previousLineOfCode.Type == LineOfCodeType.XmlComment && currentLineOfCode.Type == LineOfCodeType.WhiteSpace; + var isCommentAfterXmlComment = + previousLineOfCode.Type == LineOfCodeType.XmlComment && + currentLineOfCode.Type == LineOfCodeType.Comment; - if (!isEmptyLineAfterXmlComment) + if (isCommentAfterXmlComment) + { + AppendReindented(ref sink, currentLineOfCode.Span, GetIndentation(previousLineOfCode.Span)); + } + else if (!isEmptyLineAfterXmlComment) { sink.Append(currentLineOfCode.Span); } @@ -81,6 +88,32 @@ public static Memory CleanupFormatting(string sourceCode) 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); @@ -123,8 +156,7 @@ private static string GetUnqualifiedAttributeName(NameSyntax name) { QualifiedNameSyntax qualifiedName => qualifiedName.Right, AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name, - SimpleNameSyntax unqualifiedName => unqualifiedName, - _ => throw new ArgumentOutOfRangeException(nameof(name)), + _ => (SimpleNameSyntax) name, }; var identifier = simpleName.Identifier.ValueText; const string attributeSuffix = "Attribute"; @@ -178,6 +210,10 @@ public bool TryGetNext(out LineOfCode lineOfCode) { lineOfCode = new (LineOfCodeType.XmlComment, currentLineSpan); } + else if (leftTrimmedSpan.StartsWith("//")) + { + lineOfCode = new (LineOfCodeType.Comment, currentLineSpan); + } else if (leftTrimmedSpan.IsEmpty) { lineOfCode = new (LineOfCodeType.WhiteSpace, currentLineSpan); @@ -209,6 +245,7 @@ private enum LineOfCodeType { Other, XmlComment, + Comment, WhiteSpace, } } From 7dab9cdfce0ce228c49b8f82b8d37ba6be43ea87 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 24 Jul 2026 22:06:14 +0200 Subject: [PATCH 4/4] ci: add verify generated single file job Signed-off-by: Kenny Pflug --- .github/workflows/build-and-test.yml | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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