From e57ed3e8bf23c1a0d6ae6df3f2eebfc66801fdf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 16:28:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20NE0003=20=E2=80=94=20only=20one=20n?= =?UTF-8?q?amespace=20declaration=20per=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report when a single file declares more than one namespace, whether sibling or nested (block- and file-scoped). A file that declares multiple namespaces hides types from the name-to-location mapping the other organization rules establish, and complicates NE0001/NE0002. Exactly one namespace per file. - SingleNamespacePerFileAnalyzer flags every namespace declaration except the first, and records whether the reported declaration is nested - Code fix flattens the nested case into a single file-scoped namespace that matches the folder-derived namespace (via the shared FolderNamespace helper), falling back to the concatenated nesting when no anchor is available; the multiple-sibling case is left to NE0001's move-type fix - Honors the file-organization opt-outs and skips generated code - Unit + integration tests, docs/rules/NE0003.md Closes #5 --- docs/rules/NE0003.md | 55 ++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../DiagnosticDescriptors.cs | 14 ++ src/NetEvolve.Analyzer/DiagnosticIds.cs | 5 + .../SingleNamespacePerFileAnalyzer.cs | 81 +++++++++ .../SingleNamespacePerFileCodeFixProvider.cs | 161 ++++++++++++++++++ .../SingleNamespaceCodeFixRunner.cs | 136 +++++++++++++++ .../SingleNamespacePerFileAnalyzerTests.cs | 94 ++++++++++ .../SingleNamespacePerFileCodeFixTests.cs | 120 +++++++++++++ .../SingleNamespacePerFileAnalyzerTests.cs | 123 +++++++++++++ .../SingleNamespacePerFileCodeFixTests.cs | 41 +++++ .../SingleNamespacePerFileCodeFixVerifier.cs | 59 +++++++ .../SingleNamespacePerFileVerifier.cs | 60 +++++++ 13 files changed, 950 insertions(+) create mode 100644 docs/rules/NE0003.md create mode 100644 src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs create mode 100644 src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.cs diff --git a/docs/rules/NE0003.md b/docs/rules/NE0003.md new file mode 100644 index 0000000..095ee41 --- /dev/null +++ b/docs/rules/NE0003.md @@ -0,0 +1,55 @@ +# NE0003: Declare a single namespace per file + +| Property | Value | +|------------|------------------| +| Rule ID | NE0003 | +| Category | Maintainability | +| Severity | Warning | +| Code fix | Yes | + +## Cause + +A file declares more than one namespace. This covers both shapes: sibling namespaces (two or more +declarations at the top level) and nested namespaces (one declared inside another), whether written with block +(`namespace X { ... }`) or file-scoped (`namespace X;`) syntax. + +## Rule description + +Keeping one namespace per file keeps types findable: the folder-to-namespace and name-to-file conventions the +other organization rules establish only hold when a file maps to a single namespace. + +- All namespace declarations in the file are collected in document order. +- When there is more than one, every declaration except the first (the outermost, which is kept) is flagged. +- Generated code is skipped. + +## How to fix violations + +Split the file so each namespace lives in its own file, or collapse a nested namespace into a single one. +A code fix is provided for the nested shape: + +- **Flatten to a single namespace** — offered when the flagged namespace is nested inside another. It rewrites + the whole file to a single file-scoped namespace containing every top-level type. The target namespace is + the folder-derived namespace (`RootNamespace` joined with the file's folder path relative to `ProjectDir`); + when that anchor is unavailable it falls back to the literal concatenation of the nested namespace chain + (for example `Outer.Inner`). + +Multiple **sibling** namespaces are not flattened here — that is resolved via NE0001's *move type* fix, which +relocates the extra types into their own correctly named files. + +## Configuration + +```xml + + + true + +``` + +The rules are also disabled automatically for single-file deployments (`PublishSingleFile=true`). + +## Suppress a warning + +```csharp +#pragma warning disable NE0003 +#pragma warning restore NE0003 +``` diff --git a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md index 726b5f5..2b1d74d 100644 --- a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md +++ b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- NE0001 | Maintainability | Warning | OneTypePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0001.md) NE0002 | Maintainability | Warning | NamespaceMatchesFolderAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0002.md) +NE0003 | Maintainability | Warning | SingleNamespacePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0003.md) diff --git a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs index 055b673..5eef74a 100644 --- a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs +++ b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs @@ -35,4 +35,18 @@ internal static class DiagnosticDescriptors + "physical and logical layout stay aligned.", helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0002) ); + + /// NE0003 — a file should declare exactly one namespace. + public static readonly DiagnosticDescriptor SingleNamespacePerFile = new( + id: DiagnosticIds.NE0003, + title: "Declare a single namespace per file", + messageFormat: "Declare exactly one namespace per file", + category: DiagnosticCategories.Maintainability, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A file that declares more than one namespace (sibling or nested) hides types from the " + + "name-to-location mapping the other organization rules establish. Declare exactly one namespace " + + "per file.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0003) + ); } diff --git a/src/NetEvolve.Analyzer/DiagnosticIds.cs b/src/NetEvolve.Analyzer/DiagnosticIds.cs index b813802..ea579b1 100644 --- a/src/NetEvolve.Analyzer/DiagnosticIds.cs +++ b/src/NetEvolve.Analyzer/DiagnosticIds.cs @@ -29,6 +29,11 @@ internal static class DiagnosticIds /// public const string NE0002 = Prefix + "0002"; + /// + /// NE0003 — a file should declare exactly one namespace. + /// + public const string NE0003 = Prefix + "0003"; + /// Builds the documentation help link for a diagnostic identifier. /// The diagnostic identifier, e.g. NE0001. /// An absolute URI pointing at the rule's documentation. diff --git a/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs new file mode 100644 index 0000000..46cb62a --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs @@ -0,0 +1,81 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// NE0003 — reports when a file declares more than one namespace. All namespace declarations (block- and +/// file-scoped, including nested) are collected in document order; when there is more than one, every +/// declaration except the first is flagged, so a file always narrows to a single namespace. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SingleNamespacePerFileAnalyzer : DiagnosticAnalyzer +{ + /// Diagnostic property key: "true" when the flagged namespace is nested inside another. + internal const string NestedProperty = "Nested"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.SingleNamespacePerFile); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxTreeAction(AnalyzeTree); + } + + private static void AnalyzeTree(SyntaxTreeAnalysisContext context) + { + var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + if ( + GetBoolean(globalOptions, BuildProperty.DisableFileOrganizationRules) + || GetBoolean(globalOptions, BuildProperty.PublishSingleFile) + ) + { + return; + } + + var root = context.Tree.GetRoot(context.CancellationToken); + + // Document order (pre-order) puts a parent namespace before the child it contains, so the first + // declaration is always the outermost one and is the one we keep. + var namespaces = root.DescendantNodes().OfType().ToList(); + if (namespaces.Count <= 1) + { + return; + } + + for (var index = 1; index < namespaces.Count; index++) + { + var declaration = namespaces[index]; + + // Surface whether the flagged declaration is nested so the code fix offers flatten only for the + // nested shape; the sibling shape is left to NE0001's move-type fix. + var nested = declaration.Ancestors().OfType().Any(); + var value = nested ? "true" : "false"; + var properties = ImmutableDictionary.Empty.Add(NestedProperty, value); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.SingleNamespacePerFile, + declaration.Name.GetLocation(), + properties + ) + ); + } + } + + private static bool GetBoolean(AnalyzerConfigOptions options, string key) => + options.TryGetValue(key, out var value) && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs new file mode 100644 index 0000000..9eb30db --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs @@ -0,0 +1,161 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +/// +/// Code fix for NE0003. Offered only for the nested shape +/// (Nested == "true"): flattens the whole file to a single file-scoped namespace holding every top-level +/// type. The sibling shape is intentionally left to NE0001's move-type fix, so no action is offered there. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SingleNamespacePerFileCodeFixProvider))] +[Shared] +public sealed class SingleNamespacePerFileCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0003); + + /// + // A whole-file rewrite does not compose safely across many diagnostics, so no batch fix-all. + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics[0]; + + // Only the nested shape is flattened here; the sibling shape is resolved via NE0001's move-type fix. + var nested = string.Equals( + diagnostic.Properties[SingleNamespacePerFileAnalyzer.NestedProperty], + "true", + StringComparison.Ordinal + ); + if (!nested) + { + return; + } + + var root = (CompilationUnitSyntax) + (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false))!; + var declaration = (BaseNamespaceDeclarationSyntax) + root.FindNode(diagnostic.Location.SourceSpan) + .AncestorsAndSelf() + .First(node => node is BaseNamespaceDeclarationSyntax); + + var target = ResolveTargetNamespace(context.Document, declaration); + + context.RegisterCodeFix( + CodeAction.Create( + "Flatten to a single namespace", + cancellationToken => FlattenAsync(context.Document, target, cancellationToken), + equivalenceKey: "NE0003.Flatten" + ), + diagnostic + ); + } + + private static async Task FlattenAsync( + Document document, + string target, + CancellationToken cancellationToken + ) + { + var root = (CompilationUnitSyntax)(await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false))!; + + // Preserve the original file's final-newline style: trim trailing blank lines left by the rewrite, then + // re-add a single newline only if the source had one. + var endsWithNewline = root.ToFullString().EndsWith("\n", StringComparison.Ordinal); + var newText = WithTrailingNewline(BuildNewFileText(root, target), endsWithNewline); + + return document.WithText(SourceText.From(newText)); + } + + private static string ResolveTargetNamespace(Document document, BaseNamespaceDeclarationSyntax declaration) + { + // Prefer the folder-derived namespace so the flattened file lands where the folder layout implies; fall + // back to the literal dotted concatenation of the nested namespace chain when no mapping is available. + var options = document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions; + var filePath = document.FilePath ?? string.Empty; + + return FolderNamespace.TryResolve(options, filePath, out var expected) ? expected : NamespaceChain(declaration); + } + + private static string BuildNewFileText(CompilationUnitSyntax root, string namespaceName) + { + // Assemble the new file as text: the file-level usings, a single file-scoped namespace, then every + // top-level type rendered at column 0 so its (possibly nested) indentation is dropped and leading doc + // comments travel with it. + var builder = new StringBuilder(); + + foreach (var directive in root.Usings) + { + _ = builder.Append(directive.ToString()).Append('\n'); + } + + if (root.Usings.Count != 0) + { + _ = builder.Append('\n'); + } + + _ = builder.Append("namespace ").Append(namespaceName).Append(";\n\n"); + + var members = root.DescendantNodes().Where(IsTopLevelTypeDeclaration).Cast(); + return builder.Append(string.Join("\n\n", members.Select(RenderMember))).ToString(); + } + + private static string WithTrailingNewline(string text, bool trailingNewline) => + trailingNewline ? text.TrimEnd() + "\n" : text.TrimEnd(); + + // Renders a top-level member at column 0, keeping its leading doc comments/comments and inner blank lines but + // dropping the surrounding blank lines and the indentation it had in its original (nested) context. + private static string RenderMember(MemberDeclarationSyntax member) + { + var lines = member.ToFullString().Replace("\r\n", "\n").Split('\n').ToList(); + + while (lines.Count != 0 && lines[0].Trim().Length == 0) + { + lines.RemoveAt(0); + } + + while (lines.Count != 0 && lines[lines.Count - 1].Trim().Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + var indent = lines[0].Length - lines[0].TrimStart().Length; + return string.Join( + "\n", + lines.Select(line => line.Length >= indent ? line.Substring(indent) : line.TrimStart()) + ); + } + + private static bool IsTopLevelTypeDeclaration(SyntaxNode node) => + node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax + && node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax; + + private static string NamespaceChain(SyntaxNode node) + { + var segments = new List(); + for (var current = node; current is not null; current = current.Parent) + { + if (current is BaseNamespaceDeclarationSyntax namespaceDeclaration) + { + segments.Add(namespaceDeclaration.Name.ToString()); + } + } + + segments.Reverse(); + return string.Join(".", segments); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs new file mode 100644 index 0000000..ad6d8d7 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs @@ -0,0 +1,136 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Drives end-to-end through a real : +/// builds a project from a single document (with an explicit file path so the folder-derived namespace resolves), +/// runs the analyzer to obtain the NE0003 diagnostic, registers the fix, applies the resulting +/// , and returns the final set of documents. +/// +internal static class SingleNamespaceCodeFixRunner +{ + private static readonly ImmutableArray _references = ResolveFrameworkReferences(); + + public static async Task> ApplyAsync( + string name, + string content, + string filePath, + (string Key, string Value)[]? properties = null, + CancellationToken cancellationToken = default + ) + { + using var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var solution = BuildSolution(workspace, projectId, name, content, filePath, properties); + + var changed = await ApplyFixAsync(solution, projectId, cancellationToken).ConfigureAwait(false); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var document in changed.GetProject(projectId)!.Documents) + { + result[document.Name] = (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString(); + } + + return result; + } + + private static Solution BuildSolution( + AdhocWorkspace workspace, + ProjectId projectId, + string name, + string content, + string filePath, + (string Key, string Value)[]? properties + ) + { + var projectInfo = ProjectInfo + .Create(projectId, VersionStamp.Default, "Sample", "Sample", LanguageNames.CSharp) + .WithMetadataReferences(_references) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var solution = workspace + .CurrentSolution.AddProject(projectInfo) + .AddDocument(DocumentId.CreateNewId(projectId), name, SourceText.From(content), filePath: filePath); + + if (properties is not { Length: > 0 }) + { + return solution; + } + + var builder = new StringBuilder("is_global = true\n"); + foreach (var (key, value) in properties) + { + _ = builder.Append("build_property.").Append(key).Append(" = ").Append(value).Append('\n'); + } + + return solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId), + ".globalconfig", + SourceText.From(builder.ToString()), + filePath: "/.globalconfig" + ); + } + + private static async Task ApplyFixAsync( + Solution solution, + ProjectId projectId, + CancellationToken cancellationToken + ) + { + var project = solution.GetProject(projectId)!; + var compilation = (await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false))!; + + // S8949: the cancellation-token WithAnalyzers overload is obsolete; cancellation is honored by + // GetAnalyzerDiagnosticsAsync below. +#pragma warning disable S8949 + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new SingleNamespacePerFileAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + var diagnostic = diagnostics.First(d => string.Equals(d.Id, DiagnosticIds.NE0003, StringComparison.Ordinal)); + var document = solution.GetDocument(diagnostic.Location.SourceTree)!; + + var actions = new List(); + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + await new SingleNamespacePerFileCodeFixProvider().RegisterCodeFixesAsync(context).ConfigureAwait(false); + + if (actions.Count == 0) + { + return solution; + } + + var operations = await actions[0].GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().First().ChangedSolution; + } + + private static ImmutableArray ResolveFrameworkReferences() + { + var trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!; + + return + [ + .. trustedAssemblies + .Split(Path.PathSeparator) + .Where(path => path.Length != 0) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)), + ]; + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs new file mode 100644 index 0000000..5d2bd24 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs @@ -0,0 +1,94 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NetEvolve.Analyzer; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end tests for NE0003 through the real +/// pipeline. The rule is count-based and does not depend on the file path, so these hold with or without one. +/// +public sealed class SingleNamespacePerFileAnalyzerTests +{ + private static bool IsNe0003(Microsoft.CodeAnalysis.Diagnostic diagnostic) => + string.Equals(diagnostic.Id, DiagnosticIds.NE0003, StringComparison.Ordinal); + + [Test] + public async Task SiblingNamespaces_ReportsNe0003() + { + const string source = """ + namespace First + { + public sealed class One { } + } + + namespace Second + { + public sealed class Two { } + } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Types.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1); + } + + [Test] + public async Task NestedNamespaces_ReportsNe0003() + { + const string source = """ + namespace Outer + { + namespace Inner + { + public sealed class Circle { } + } + } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Circle.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1); + } + + [Test] + public async Task SingleNamespace_ReportsNothing() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Circle.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0003)).IsFalse(); + } + + [Test] + public async Task WithoutFilePath_StillReportsNe0003() + { + const string source = """ + namespace First { } + + namespace Second { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer()) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs new file mode 100644 index 0000000..c4e1669 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs @@ -0,0 +1,120 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Threading.Tasks; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end tests for the NE0003 code fix through a real AdhocWorkspace (see +/// ): flattening a nested namespace either to the folder-derived +/// namespace or, when no anchor is available, to the concatenated nested chain. +/// +public sealed class SingleNamespacePerFileCodeFixTests +{ + private const string Nested = """ + namespace Outer + { + namespace Inner + { + public sealed class Circle { } + } + } + """; + + [Test] + public async Task Nested_NoProperties_UsesConcatenatedChainFallback() + { + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Circle.cs", Nested, filePath: "Circle.cs") + .ConfigureAwait(false); + + await Assert + .That( + result.TryGetValue("Circle.cs", out var text) + && text.Contains("namespace Outer.Inner;", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task Nested_WithRootAndProjectDir_UsesFolderDerivedNamespace() + { + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync( + "Circle.cs", + Nested, + filePath: "/proj/Shapes/Circle.cs", + properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")] + ) + .ConfigureAwait(false); + + await Assert + .That( + result.TryGetValue("Circle.cs", out var text) + && text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task Nested_WithUsings_CarriesUsingsIntoFlattenedFile() + { + const string source = """ + using System; + + namespace Outer + { + namespace Inner + { + public sealed class Circle + { + public DateTime Now { get; } + } + } + } + """; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Circle.cs", source, filePath: "Circle.cs") + .ConfigureAwait(false); + + await Assert + .That( + result.TryGetValue("Circle.cs", out var text) + && text.Contains("using System;", StringComparison.Ordinal) + && text.Contains("namespace Outer.Inner;", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task SiblingNamespaces_OfferNoFix_LeavesFileUnchanged() + { + const string source = """ + namespace Alpha + { + public sealed class One { } + } + + namespace Beta + { + public sealed class Two { } + } + """; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Types.cs", source, filePath: "Types.cs") + .ConfigureAwait(false); + + // The sibling case is left to NE0001's move fix, so NE0003 offers no action and the file is untouched. + await Assert + .That( + result.TryGetValue("Types.cs", out var text) + && text.Contains("namespace Alpha", StringComparison.Ordinal) + && text.Contains("namespace Beta", StringComparison.Ordinal) + ) + .IsTrue(); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs new file mode 100644 index 0000000..aac3d15 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs @@ -0,0 +1,123 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Threading.Tasks; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// Unit tests for SingleNamespacePerFileAnalyzer (NE0003), driven through the verifier harness. +public sealed class SingleNamespacePerFileAnalyzerTests +{ + [Test] + public async Task Initialize_NullContext_ThrowsArgumentNullException() + { + var analyzer = new SingleNamespacePerFileAnalyzer(); + ArgumentNullException? caught = null; + + try + { + analyzer.Initialize(null!); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } + + // ---- Compliant: exactly one namespace --------------------------------------------------------------- + + [Test] + public Task SingleFileScopedNamespace_NoDiagnostic() => + SingleNamespacePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry; + + public sealed class Circle { } + """ + ); + + [Test] + public Task SingleBlockNamespace_NoDiagnostic() => + SingleNamespacePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry + { + public sealed class Circle { } + } + """ + ); + + // ---- Non-compliant: more than one namespace --------------------------------------------------------- + + [Test] + public Task TwoSiblingNamespaces_SecondFlagged() => + SingleNamespacePerFileVerifier.VerifyAsync( + "Types.cs", + """ + namespace First + { + public sealed class One { } + } + + namespace {|NE0003:Second|} + { + public sealed class Two { } + } + """ + ); + + [Test] + public Task NestedNamespaces_InnerFlagged() => + SingleNamespacePerFileVerifier.VerifyAsync( + "Types.cs", + """ + namespace A + { + namespace {|NE0003:B|} + { + public sealed class Circle { } + } + } + """ + ); + + // ---- Opt-outs --------------------------------------------------------------------------------------- + + [Test] + public Task Disabled_ViaBuildProperty_NoDiagnostic() => + SingleNamespacePerFileVerifier.VerifyAsync( + [ + ( + "Types.cs", + """ + namespace First { } + + namespace Second { } + """ + ), + ], + ("NetEvolveAnalyzerDisableFileOrganizationRules", "true") + ); + + [Test] + public Task Disabled_ForSingleFilePublish_NoDiagnostic() => + SingleNamespacePerFileVerifier.VerifyAsync( + [ + ( + "Types.cs", + """ + namespace First { } + + namespace Second { } + """ + ), + ], + ("PublishSingleFile", "true") + ); +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs new file mode 100644 index 0000000..cb66982 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs @@ -0,0 +1,41 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Threading.Tasks; +using TUnit.Core; + +/// +/// Tests for SingleNamespacePerFileCodeFixProvider (NE0003): flattening a nested namespace to a single +/// file-scoped namespace. Without RootNamespace/ProjectDir the target is the literal dotted +/// concatenation of the nested chain, so the outcome is deterministic in the unit harness. +/// +public sealed class SingleNamespacePerFileCodeFixTests +{ + [Test] + public Task Nested_Flatten_UsesConcatenatedChainFallback() => + SingleNamespacePerFileCodeFixVerifier.VerifyAsync( + [ + ( + "Circle.cs", + """ + namespace Outer + { + namespace {|NE0003:Inner|} + { + public sealed class Circle { } + } + } + """ + ), + ], + [ + ( + "Circle.cs", + """ + namespace Outer.Inner; + + public sealed class Circle { } + """ + ), + ] + ); +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs new file mode 100644 index 0000000..b1c7eb2 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs @@ -0,0 +1,59 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Applies to named source files and asserts the resulting +/// set of files equals the expected fixed sources. Build properties are injected through a global analyzer +/// config, mirroring . +/// +internal static class SingleNamespacePerFileCodeFixVerifier +{ + public static async Task VerifyAsync( + (string Name, string Content)[] sources, + (string Name, string Content)[] fixedSources, + params (string Key, string Value)[] properties + ) + { + var test = new CSharpCodeFixTest< + SingleNamespacePerFileAnalyzer, + SingleNamespacePerFileCodeFixProvider, + DefaultVerifier + > + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + + foreach (var (name, content) in sources) + { + test.TestState.Sources.Add((name, content)); + } + + foreach (var (name, content) in fixedSources) + { + test.FixedState.Sources.Add((name, content)); + } + + if (properties.Length > 0) + { + var builder = new StringBuilder("is_global = true\n"); + foreach (var (key, value) in properties) + { + _ = builder.Append("build_property.").Append(key).Append(" = ").Append(value).Append('\n'); + } + + // Declare the global config in both states: the fix carries it into the fixed solution, so the + // expected FixedState must contain it too, otherwise the analyzer-config comparison fails. + var config = builder.ToString(); + test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", config)); + test.FixedState.AnalyzerConfigFiles.Add(("/.globalconfig", config)); + } + + await test.RunAsync(CancellationToken.None).ConfigureAwait(false); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.cs new file mode 100644 index 0000000..efd8aba --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.cs @@ -0,0 +1,60 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Runs against one or more named source files and, +/// optionally, a set of MSBuild build properties injected through a global analyzer config. Diagnostics are +/// declared inline with {|NE0003:Identifier|} markup. +/// +internal static class SingleNamespacePerFileVerifier +{ + public static async Task VerifyAsync( + (string Name, string Content)[] sources, + params (string Key, string Value)[] properties + ) + { + var test = new CSharpAnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + + foreach (var (name, content) in sources) + { + test.TestState.Sources.Add((name, content)); + } + + if (properties.Length > 0) + { + var builder = new StringBuilder("is_global = true\n"); + foreach (var (key, value) in properties) + { + _ = builder.Append("build_property.").Append(key).Append(" = ").Append(value).Append('\n'); + } + + var config = builder.ToString(); + test.SolutionTransforms.Add( + (solution, projectId) => + solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId), + ".globalconfig", + SourceText.From(config), + filePath: "/.globalconfig" + ) + ); + } + + await test.RunAsync(CancellationToken.None).ConfigureAwait(false); + } + + /// Convenience overload for a single named source file. + public static Task VerifyAsync(string name, string content, params (string Key, string Value)[] properties) => + VerifyAsync([(name, content)], properties); +} From fe69e6071ee192b02a1e2f74ca437b767b9b0598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 17:08:20 +0200 Subject: [PATCH 2/2] test: cover NE0003 analyzer opt-outs and flatten rendering branches Raise NE0003 patch coverage over the 95% gate by exercising the analyzer and flatten fix from both the unit and integration flags: - Add an AdhocWorkspace-based runner to the unit suite so the flatten fix runs end-to-end there too (a line hit by only one flag counts as a partial) - Cover the member-rendering branches (leading/interior blank lines, usings, enum and delegate members, trailing-newline style, folder-derived vs concatenated target) - Add the analyzer null-argument guard and the PublishSingleFile / DisableFileOrganizationRules opt-outs to the integration suite --- .../SingleNamespacePerFileAnalyzerTests.cs | 60 ++++++++ .../SingleNamespacePerFileCodeFixTests.cs | 52 +++++++ .../SingleNamespaceCodeFixRunner.cs | 136 ++++++++++++++++++ ...ingleNamespacePerFileCodeFixRunnerTests.cs | 77 ++++++++++ 4 files changed, 325 insertions(+) create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs index 5d2bd24..e7776b3 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs @@ -91,4 +91,64 @@ namespace Second { } await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1); } + + [Test] + public async Task Disabled_ViaBuildProperty_ReportsNothing() + { + const string source = """ + namespace First { } + + namespace Second { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new SingleNamespacePerFileAnalyzer(), + path: "Types.cs", + properties: [("NetEvolveAnalyzerDisableFileOrganizationRules", "true")] + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0003)).IsFalse(); + } + + [Test] + public async Task Disabled_ForSingleFilePublish_ReportsNothing() + { + const string source = """ + namespace First { } + + namespace Second { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new SingleNamespacePerFileAnalyzer(), + path: "Types.cs", + properties: [("PublishSingleFile", "true")] + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0003)).IsFalse(); + } + + [Test] + public async Task Initialize_NullContext_ThrowsArgumentNullException() + { + var analyzer = new SingleNamespacePerFileAnalyzer(); + ArgumentNullException? caught = null; + + try + { + analyzer.Initialize(null!); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } } diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs index c4e1669..0d64ceb 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs @@ -2,6 +2,7 @@ namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; using System; using System.Threading.Tasks; +using NetEvolve.Analyzer.Maintainability; using TUnit.Assertions; using TUnit.Assertions.Extensions; using TUnit.Core; @@ -117,4 +118,55 @@ await Assert ) .IsTrue(); } + + [Test] + public async Task Nested_Rich_RendersEveryMemberKindAtColumnZero() + { + // Usings, a doc comment, a leading blank line, an interior blank line, plus enum and delegate members + // exercise the full member-rendering path. No anchor properties, so the target is the concatenated chain. + const string source = + "using System;\n\nnamespace Outer\n{\n namespace Inner\n {\n\n" + + " /// A circle.\n public sealed class Circle\n {\n" + + " public int X { get; }\n\n public int Y { get; }\n }\n\n" + + " public enum Kind { A }\n\n public delegate void Handler();\n }\n}\n"; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Types.cs", source, filePath: "Types.cs") + .ConfigureAwait(false); + + var text = result["Types.cs"]; + await Assert.That(text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("using System;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("public enum Kind { A }", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("public delegate void Handler();", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("\npublic sealed class Circle", StringComparison.Ordinal)).IsTrue(); + } + + [Test] + public async Task Nested_NoTrailingNewline_PreservesStyleAndUsesFolderNamespace() + { + const string source = + "namespace Outer\n{\n namespace Inner\n {\n public sealed class Circle { }\n }\n}"; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync( + "Circle.cs", + source, + filePath: "/proj/Shapes/Circle.cs", + properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")] + ) + .ConfigureAwait(false); + + var text = result["Circle.cs"]; + await Assert.That(text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.EndsWith('\n')).IsFalse(); + } + + [Test] + public async Task GetFixAllProvider_ReturnsNull() + { + var provider = new SingleNamespacePerFileCodeFixProvider(); + + await Assert.That(provider.GetFixAllProvider()).IsNull(); + } } diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs new file mode 100644 index 0000000..17e0b63 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs @@ -0,0 +1,136 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Drives end-to-end through a real +/// from the unit suite as well, so the NE0003 flatten fix is exercised by both the unit and integration flags +/// (a line covered by only one flag counts as a partial against the patch coverage gate). +/// +internal static class SingleNamespaceCodeFixRunner +{ + private static readonly ImmutableArray _references = ResolveFrameworkReferences(); + + public static async Task> ApplyAsync( + string name, + string content, + string filePath, + (string Key, string Value)[]? properties = null, + CancellationToken cancellationToken = default + ) + { + using var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var solution = BuildSolution(workspace, projectId, name, content, filePath, properties); + + var changed = await ApplyFixAsync(solution, projectId, cancellationToken).ConfigureAwait(false); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var document in changed.GetProject(projectId)!.Documents) + { + result[document.Name] = (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString(); + } + + return result; + } + + private static Solution BuildSolution( + AdhocWorkspace workspace, + ProjectId projectId, + string name, + string content, + string filePath, + (string Key, string Value)[]? properties + ) + { + var projectInfo = ProjectInfo + .Create(projectId, VersionStamp.Default, "Sample", "Sample", LanguageNames.CSharp) + .WithMetadataReferences(_references) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var solution = workspace + .CurrentSolution.AddProject(projectInfo) + .AddDocument(DocumentId.CreateNewId(projectId), name, SourceText.From(content), filePath: filePath); + + if (properties is not { Length: > 0 }) + { + return solution; + } + + var builder = new StringBuilder("is_global = true\n"); + foreach (var (key, value) in properties) + { + _ = builder.Append("build_property.").Append(key).Append(" = ").Append(value).Append('\n'); + } + + return solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId), + ".globalconfig", + SourceText.From(builder.ToString()), + filePath: "/.globalconfig" + ); + } + + private static async Task ApplyFixAsync( + Solution solution, + ProjectId projectId, + CancellationToken cancellationToken + ) + { + var project = solution.GetProject(projectId)!; + var compilation = (await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false))!; + + // S8949: the cancellation-token WithAnalyzers overload is obsolete; cancellation is honored by + // GetAnalyzerDiagnosticsAsync below. +#pragma warning disable S8949 + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new SingleNamespacePerFileAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + var diagnostic = diagnostics.First(d => string.Equals(d.Id, DiagnosticIds.NE0003, StringComparison.Ordinal)); + var document = solution.GetDocument(diagnostic.Location.SourceTree)!; + + var actions = new List(); + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + await new SingleNamespacePerFileCodeFixProvider().RegisterCodeFixesAsync(context).ConfigureAwait(false); + + if (actions.Count == 0) + { + return solution; + } + + var operations = await actions[0].GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().First().ChangedSolution; + } + + private static ImmutableArray ResolveFrameworkReferences() + { + var trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!; + + return + [ + .. trustedAssemblies + .Split(Path.PathSeparator) + .Where(path => path.Length != 0) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)), + ]; + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs new file mode 100644 index 0000000..6a68755 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs @@ -0,0 +1,77 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Threading.Tasks; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end unit tests for the NE0003 flatten fix through a real AdhocWorkspace (see +/// ). These mirror the integration coverage so the fix is exercised +/// by both test flags, and cover the rendering branches (leading/interior blank lines, usings, enum/delegate +/// members, trailing-newline style, folder-derived vs concatenated target). +/// +public sealed class SingleNamespacePerFileCodeFixRunnerTests +{ + // Nested file with usings, a doc comment, a leading blank line, an interior blank line, and enum/delegate + // members — exercises the full member-rendering path. No anchor properties, so the target is the + // concatenated nesting chain. + private const string Rich = + "using System;\n\nnamespace Outer\n{\n namespace Inner\n {\n\n" + + " /// A circle.\n public sealed class Circle\n {\n" + + " public int X { get; }\n\n public int Y { get; }\n }\n\n" + + " public enum Kind { A }\n\n public delegate void Handler();\n }\n}\n"; + + [Test] + public async Task Nested_Rich_FlattensToConcatenatedChain() + { + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Types.cs", Rich, filePath: "Types.cs") + .ConfigureAwait(false); + + var text = result["Types.cs"]; + await Assert.That(text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("using System;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("/// A circle.", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("public enum Kind { A }", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("public delegate void Handler();", StringComparison.Ordinal)).IsTrue(); + // De-indented to column 0. + await Assert.That(text.Contains("\npublic sealed class Circle", StringComparison.Ordinal)).IsTrue(); + } + + [Test] + public async Task Nested_NoTrailingNewline_WithAnchor_UsesFolderNamespace() + { + const string source = + "namespace Outer\n{\n namespace Inner\n {\n public sealed class Circle { }\n }\n}"; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync( + "Circle.cs", + source, + filePath: "/proj/Shapes/Circle.cs", + properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")] + ) + .ConfigureAwait(false); + + var text = result["Circle.cs"]; + await Assert.That(text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.EndsWith('\n')).IsFalse(); + } + + [Test] + public async Task SiblingNamespaces_OfferNoFix_LeavesFileUnchanged() + { + const string source = + "namespace Alpha\n{\n public sealed class One { }\n}\n\nnamespace Beta\n{\n public sealed class Two { }\n}\n"; + + var result = await SingleNamespaceCodeFixRunner + .ApplyAsync("Types.cs", source, filePath: "Types.cs") + .ConfigureAwait(false); + + var text = result["Types.cs"]; + await Assert.That(text.Contains("namespace Alpha", StringComparison.Ordinal)).IsTrue(); + await Assert.That(text.Contains("namespace Beta", StringComparison.Ordinal)).IsTrue(); + } +}