diff --git a/src/NetEvolve.Analyzer/Builders/NamespaceFileBuilder.cs b/src/NetEvolve.Analyzer/Builders/NamespaceFileBuilder.cs new file mode 100644 index 0000000..fa99231 --- /dev/null +++ b/src/NetEvolve.Analyzer/Builders/NamespaceFileBuilder.cs @@ -0,0 +1,90 @@ +namespace NetEvolve.Analyzer.Builders; + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// Shared file-text assembly for the file-organization code fixes. Builds a new source file from a set of +/// top-level type declarations under a single file-scoped namespace, rendering each member at column 0 so any +/// indentation from a nested (block) namespace is dropped without re-indenting — which is what corrupted +/// multi-line string literals. Used by NE0001's move-type fix and NE0003's flatten fix so both emit identical +/// layout. +/// +internal static class NamespaceFileBuilder +{ + /// + /// Assembles the new file as text: the file-level usings, an optional file-scoped namespace with the FULL + /// dotted (omitted when empty), then every member in + /// rendered from its full text so leading doc comments travel with it. + /// + public static string Build( + CompilationUnitSyntax root, + string namespaceName, + IReadOnlyList members + ) + { + var builder = new StringBuilder(); + + foreach (var directive in root.Usings) + { + _ = builder.Append(directive.ToString()).Append('\n'); + } + + if (root.Usings.Count != 0) + { + _ = builder.Append('\n'); + } + + if (namespaceName.Length != 0) + { + _ = builder.Append("namespace ").Append(namespaceName).Append(";\n\n"); + } + + return builder.Append(string.Join("\n\n", members.Select(RenderMember))).ToString(); + } + + /// + /// Preserves the original file's final-newline style: trims trailing blank lines left by an edit, then + /// re-adds a single newline only when is . + /// + public static string WithTrailingNewline(string text, bool trailingNewline) => + trailingNewline ? text.TrimEnd() + "\n" : text.TrimEnd(); + + /// The top-level type declarations (block- or file-scoped) of . + public static IEnumerable TopLevelTypeDeclarations(CompilationUnitSyntax root) => + root.DescendantNodes().Where(IsTopLevelTypeDeclaration).Cast(); + + /// + /// Whether is a top-level type declaration — a type or delegate declared directly + /// under a namespace or the compilation unit. + /// + public static bool IsTopLevelTypeDeclaration(SyntaxNode node) => + node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax + && node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax; + + // Renders a 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 (possibly 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()) + ); + } +} diff --git a/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs b/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs index 6ad7f84..b7ddb74 100644 --- a/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs +++ b/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs @@ -9,8 +9,10 @@ namespace NetEvolve.Analyzer.Maintainability; /// /// Computes the namespace a file should declare from its location relative to the project directory, anchored -/// at the RootNamespace MSBuild property. Shared by NamespaceMatchesFolderAnalyzer (NE0002) and -/// the NE0003 nested-namespace flatten fix, so both derive the same folder-anchored value. +/// at the RootNamespace MSBuild property. When RootNamespace is absent or empty the namespace is +/// composed purely from the folder segments below the project directory. Shared by +/// NamespaceMatchesFolderAnalyzer (NE0002) and the NE0003 nested-namespace flatten fix, so both derive +/// the same folder-anchored value. /// internal static class FolderNamespace { @@ -18,9 +20,10 @@ internal static class FolderNamespace /// /// Resolves the folder-derived namespace for . Returns - /// when the anchor properties (RootNamespace, ProjectDir) are missing, the file lives outside - /// the project directory, or a folder segment is not a valid C# identifier — in all of which cases no - /// reliable mapping exists and the caller should stay silent. + /// when ProjectDir is missing, the file lives outside the project directory, a folder segment is not + /// a valid C# identifier, or the file sits in the project root with no RootNamespace anchor — in all + /// of which cases no reliable mapping exists and the caller should stay silent. RootNamespace is + /// optional: when it is absent or empty the returned namespace is the folder segments joined on their own. /// /// The global analyzer-config options exposing the build properties. /// The absolute (or project-relative) path of the source file. @@ -34,20 +37,22 @@ public static bool TryResolve(AnalyzerConfigOptions globalOptions, string filePa return false; } - if ( - !TryGetNonEmpty(globalOptions, BuildProperty.RootNamespace, out var rootNamespace) - || !TryGetNonEmpty(globalOptions, BuildProperty.ProjectDir, out var projectDir) - ) + if (!TryGetNonEmpty(globalOptions, BuildProperty.ProjectDir, out var projectDir)) { return false; } + // RootNamespace is optional: an absent or empty value means the namespace is composed purely from the + // folder segments below the project directory. + _ = globalOptions.TryGetValue(BuildProperty.RootNamespace, out var rawRootNamespace); + var rootNamespace = rawRootNamespace ?? string.Empty; + var directory = Path.GetDirectoryName(filePath); if (string.IsNullOrEmpty(directory)) { - // The file has no directory component, so it maps to the root namespace exactly. - expected = rootNamespace; - return true; + // The file has no directory component, so it maps to the root namespace exactly — but with no + // RootNamespace anchor there is nothing to compose from, so stay silent. + return TryUseRootNamespace(rootNamespace, ref expected); } if (!TryGetRelativeSegments(projectDir, directory!, out var segments)) @@ -57,9 +62,9 @@ public static bool TryResolve(AnalyzerConfigOptions globalOptions, string filePa if (segments.Count == 0) { - // The file sits directly in the project directory: it maps to the root namespace exactly. - expected = rootNamespace; - return true; + // The file sits directly in the project directory: it maps to the root namespace exactly, or stays + // silent when there is no RootNamespace anchor to compose from. + return TryUseRootNamespace(rootNamespace, ref expected); } if (segments.Any(segment => !SyntaxFacts.IsValidIdentifier(segment))) @@ -67,7 +72,19 @@ public static bool TryResolve(AnalyzerConfigOptions globalOptions, string filePa return false; } - expected = rootNamespace + "." + string.Join(".", segments); + var folderNamespace = string.Join(".", segments); + expected = rootNamespace.Length == 0 ? folderNamespace : rootNamespace + "." + folderNamespace; + return true; + } + + private static bool TryUseRootNamespace(string rootNamespace, ref string expected) + { + if (rootNamespace.Length == 0) + { + return false; + } + + expected = rootNamespace; return true; } diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs index 47dc2e4..a856fbe 100644 --- a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs +++ b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Analyzer.Maintainability; +namespace NetEvolve.Analyzer.Maintainability; using System; using System.Collections.Generic; @@ -6,15 +6,15 @@ namespace NetEvolve.Analyzer.Maintainability; using System.Composition; 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.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Builders; +using NetEvolve.Analyzer.Providers; /// /// Code fix for NE0001. Offers to rename the file to match its single @@ -25,13 +25,18 @@ namespace NetEvolve.Analyzer.Maintainability; [Shared] public sealed class OneTypePerFileCodeFixProvider : CodeFixProvider { + private static readonly Lazy FixAll = new( + () => new SequentialFixAllProvider(() => new OneTypePerFileAnalyzer()), + LazyThreadSafetyMode.ExecutionAndPublication + ); + /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0001); /// // Renaming and adding documents cannot compose through the default batch fixer, so a custom provider // applies the rename/move fixes sequentially and re-resolves diagnostics between each step. - public override FixAllProvider? GetFixAllProvider() => OneTypePerFileFixAllProvider.Instance; + public override FixAllProvider? GetFixAllProvider() => FixAll.Value; /// public override async Task RegisterCodeFixesAsync(CodeFixContext context) @@ -41,7 +46,9 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var root = (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false))!; var diagnostic = context.Diagnostics[0]; var declaration = (MemberDeclarationSyntax) - root.FindNode(diagnostic.Location.SourceSpan).AncestorsAndSelf().First(IsTopLevelTypeDeclaration); + root.FindNode(diagnostic.Location.SourceSpan) + .AncestorsAndSelf() + .First(NamespaceFileBuilder.IsTopLevelTypeDeclaration); var expectedName = diagnostic.Properties[OneTypePerFileAnalyzer.ExpectedFileNameProperty]!; var singleType = string.Equals( @@ -111,15 +118,16 @@ CancellationToken cancellationToken var groupGenericOverloads = ReadGroupGenericOverloads(document); var moved = MatchingDeclarations(root, declaration, groupGenericOverloads).ToList(); - // Preserve the original file's final-newline style: trim trailing blank lines left by the edit, 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, NamespaceName(declaration), moved), endsWithNewline); + var newText = NamespaceFileBuilder.WithTrailingNewline( + NamespaceFileBuilder.Build(root, NamespaceName(declaration), moved), + endsWithNewline + ); // Move fires only when the file holds several type groups and exactly one group is relocated, so the // original always keeps at least one type (the last remaining single type becomes a rename instead). var removed = root.RemoveNodes(moved, SyntaxRemoveOptions.KeepNoTrivia)!; - var remainingText = WithTrailingNewline(removed.ToFullString(), endsWithNewline); + var remainingText = NamespaceFileBuilder.WithTrailingNewline(removed.ToFullString(), endsWithNewline); var newName = expectedName + ".cs"; var newDocumentId = DocumentId.CreateNewId(document.Project.Id); @@ -135,62 +143,6 @@ CancellationToken cancellationToken ); } - private static string BuildNewFileText( - CompilationUnitSyntax root, - string namespaceName, - IReadOnlyList moved - ) - { - // Assemble the new file as text. Always emit a file-scoped namespace with the FULL dotted name (so a - // type lifted out of a nested block namespace keeps its real namespace, and no block re-indentation is - // needed — which is what corrupted multi-line string literals). Members are rendered from their full - // text so leading doc comments travel with them. - var builder = new StringBuilder(); - - foreach (var directive in root.Usings) - { - _ = builder.Append(directive.ToString()).Append('\n'); - } - - if (root.Usings.Count != 0) - { - _ = builder.Append('\n'); - } - - if (namespaceName.Length != 0) - { - _ = builder.Append("namespace ").Append(namespaceName).Append(";\n\n"); - } - - return builder.Append(string.Join("\n\n", moved.Select(RenderMember))).ToString(); - } - - private static string WithTrailingNewline(string text, bool trailingNewline) => - trailingNewline ? text.TrimEnd() + "\n" : text.TrimEnd(); - - // Renders a moved 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 (possibly 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 IEnumerable MatchingDeclarations( CompilationUnitSyntax root, MemberDeclarationSyntax declaration, @@ -201,9 +153,8 @@ bool groupGenericOverloads var arity = Arity(declaration); var @namespace = NamespaceName(declaration); - return root.DescendantNodes() - .Where(IsTopLevelTypeDeclaration) - .Cast() + return NamespaceFileBuilder + .TopLevelTypeDeclarations(root) .Where(member => string.Equals(Identifier(member).ValueText, name, StringComparison.Ordinal) && string.Equals(NamespaceName(member), @namespace, StringComparison.Ordinal) @@ -224,10 +175,6 @@ private static string SiblingPath(string currentPath, string newName) return string.IsNullOrEmpty(directory) ? newName : Path.Combine(directory, newName); } - private static bool IsTopLevelTypeDeclaration(SyntaxNode node) => - node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax - && node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax; - private static SyntaxToken Identifier(MemberDeclarationSyntax member) => member is BaseTypeDeclarationSyntax type ? type.Identifier : ((DelegateDeclarationSyntax)member).Identifier; diff --git a/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs index 9eb30db..91f0197 100644 --- a/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs +++ b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs @@ -1,11 +1,10 @@ -namespace NetEvolve.Analyzer.Maintainability; +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; @@ -13,6 +12,8 @@ namespace NetEvolve.Analyzer.Maintainability; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Builders; +using NetEvolve.Analyzer.Providers; /// /// Code fix for NE0003. Offered only for the nested shape @@ -23,12 +24,18 @@ namespace NetEvolve.Analyzer.Maintainability; [Shared] public sealed class SingleNamespacePerFileCodeFixProvider : CodeFixProvider { + private static readonly Lazy FixAll = new( + () => new SequentialFixAllProvider(() => new SingleNamespacePerFileAnalyzer()), + LazyThreadSafetyMode.ExecutionAndPublication + ); + /// 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; + // Flattening a nested file is a whole-file rewrite; the sequential fix-all re-resolves after each file so + // a batch across several files converges to a fixed point. + public override FixAllProvider? GetFixAllProvider() => FixAll.Value; /// public override async Task RegisterCodeFixesAsync(CodeFixContext context) @@ -73,10 +80,12 @@ 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 members = NamespaceFileBuilder.TopLevelTypeDeclarations(root).ToList(); var endsWithNewline = root.ToFullString().EndsWith("\n", StringComparison.Ordinal); - var newText = WithTrailingNewline(BuildNewFileText(root, target), endsWithNewline); + var newText = NamespaceFileBuilder.WithTrailingNewline( + NamespaceFileBuilder.Build(root, target, members), + endsWithNewline + ); return document.WithText(SourceText.From(newText)); } @@ -91,59 +100,6 @@ private static string ResolveTargetNamespace(Document document, BaseNamespaceDec 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(); diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs b/src/NetEvolve.Analyzer/Providers/SequentialFixAllProvider.cs similarity index 68% rename from src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs rename to src/NetEvolve.Analyzer/Providers/SequentialFixAllProvider.cs index 5f20928..0091e15 100644 --- a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs +++ b/src/NetEvolve.Analyzer/Providers/SequentialFixAllProvider.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Analyzer.Maintainability; +namespace NetEvolve.Analyzer.Providers; using System; using System.Collections.Generic; @@ -12,15 +12,25 @@ namespace NetEvolve.Analyzer.Maintainability; using Microsoft.CodeAnalysis.Diagnostics; /// -/// Custom fix-all for NE0001. The single-diagnostic fix uses -/// solution-level operations (WithDocumentName, AddDocument) and overlapping edits to a single -/// multi-type file, none of which the default batch fixer can compose. Instead this provider applies the -/// rename/move fixes one at a time and re-resolves diagnostics between each step, iterating to a fixed point. +/// A custom fix-all that applies a code fix one diagnostic at a time, re-resolving diagnostics from the +/// accumulating solution after each step until it reaches a fixed point. Some fixes cannot compose through the +/// default batch fixer: NE0001's rename/move uses solution-level operations (WithDocumentName, +/// AddDocument) and overlapping edits to a single file, and NE0003's flatten is a whole-file rewrite. The +/// code fix to run and the diagnostics to match are taken from the ; the analyzer +/// used to re-resolve between steps is supplied per rule through the constructor. /// -internal sealed class OneTypePerFileFixAllProvider : FixAllProvider +internal sealed class SequentialFixAllProvider : FixAllProvider { - /// The shared instance returned by . - public static OneTypePerFileFixAllProvider Instance { get; } = new OneTypePerFileFixAllProvider(); + private readonly Func _analyzerFactory; + + /// + /// Creates the provider for a single rule. + /// + /// + /// Produces a fresh instance of the rule's analyzer, run after each step to re-resolve diagnostics against + /// the accumulated solution. + /// + public SequentialFixAllProvider(Func analyzerFactory) => _analyzerFactory = analyzerFactory; /// public override IEnumerable GetSupportedFixAllScopes() => @@ -40,9 +50,9 @@ public override IEnumerable GetSupportedFixAllScopes() => } return CodeAction.Create( - $"Fix all '{DiagnosticIds.NE0001}' occurrences", + $"Fix all '{string.Join("', '", fixAllContext.DiagnosticIds)}' occurrences", cancellationToken => FixAllAsync(fixAllContext, cancellationToken), - equivalenceKey: nameof(OneTypePerFileFixAllProvider) + equivalenceKey: nameof(SequentialFixAllProvider) ); } @@ -77,10 +87,10 @@ private static async Task HasFixableDiagnosticsAsync(FixAllContext fixAllC return false; } - // Applies one rename/move at a time, re-resolving diagnostics from the accumulating solution after each - // step. Convergence and the move->rename flip fall out of the re-resolution; the collision case (target - // name equals the current file) registers no action and is therefore passed over without failing. - private static async Task FixAllAsync(FixAllContext fixAllContext, CancellationToken cancellationToken) + // Applies one fix at a time, re-resolving diagnostics from the accumulating solution after each step. + // Convergence (and NE0001's move->rename flip) falls out of the re-resolution; a diagnostic whose fix + // registers no action (e.g. NE0001's collision case) is passed over without failing. + private async Task FixAllAsync(FixAllContext fixAllContext, CancellationToken cancellationToken) { var solution = fixAllContext.Solution; var scope = fixAllContext.Scope; @@ -91,7 +101,7 @@ private static async Task FixAllAsync(FixAllContext fixAllContext, Can { cancellationToken.ThrowIfCancellationRequested(); - var next = await TryApplyOneAsync(solution, scope, documentId, projectId, cancellationToken) + var next = await TryApplyOneAsync(fixAllContext, solution, scope, documentId, projectId, cancellationToken) .ConfigureAwait(false); if (next is null) { @@ -102,7 +112,8 @@ private static async Task FixAllAsync(FixAllContext fixAllContext, Can } } - private static async Task TryApplyOneAsync( + private async Task TryApplyOneAsync( + FixAllContext fixAllContext, Solution solution, FixAllScope scope, DocumentId? documentId, @@ -112,7 +123,8 @@ CancellationToken cancellationToken { foreach (var id in TargetDocumentIds(solution, scope, documentId, projectId)) { - var changed = await TryFixDocumentAsync(solution, id, cancellationToken).ConfigureAwait(false); + var changed = await TryFixDocumentAsync(fixAllContext, solution, id, cancellationToken) + .ConfigureAwait(false); if (changed is not null) { return changed; @@ -122,9 +134,10 @@ CancellationToken cancellationToken return null; } - // Runs the analyzer over the document's project, then applies the first NE0001 diagnostic in the document - // (by source order) that yields an action. Returns null when the document has no applicable fix. - private static async Task TryFixDocumentAsync( + // Re-resolves the rule's diagnostics in the document, then applies the first one (by source order) that + // yields an action through the context's code fix provider. Returns null when the document has no fix. + private async Task TryFixDocumentAsync( + FixAllContext fixAllContext, Solution solution, DocumentId id, CancellationToken cancellationToken @@ -133,9 +146,9 @@ CancellationToken cancellationToken // The id always comes from TargetDocumentIds enumerating the current solution, so the document exists. var document = solution.GetDocument(id)!; - var diagnostics = await ResolveDiagnosticsAsync(solution, document, id, cancellationToken) + var diagnostics = await ResolveDiagnosticsAsync(fixAllContext, solution, document, id, cancellationToken) .ConfigureAwait(false); - var fixProvider = new OneTypePerFileCodeFixProvider(); + var fixProvider = fixAllContext.CodeFixProvider; foreach (var diagnostic in diagnostics) { @@ -150,9 +163,10 @@ CancellationToken cancellationToken return null; } - // The NE0001 diagnostics located in the document, ordered by source position, resolved from a fresh run of - // the analyzer over the current project so each pass sees the accumulated edits. - private static async Task> ResolveDiagnosticsAsync( + // The rule's diagnostics located in the document, ordered by source position, resolved from a fresh run of + // the per-rule analyzer over the current project so each pass sees the accumulated edits. + private async Task> ResolveDiagnosticsAsync( + FixAllContext fixAllContext, Solution solution, Document document, DocumentId id, @@ -166,7 +180,7 @@ CancellationToken cancellationToken // GetAnalyzerDiagnosticsAsync below. #pragma warning disable S8949 var withAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(new OneTypePerFileAnalyzer()), + ImmutableArray.Create(_analyzerFactory()), project.AnalyzerOptions ); #pragma warning restore S8949 @@ -174,7 +188,7 @@ CancellationToken cancellationToken var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); return diagnostics - .Where(diagnostic => string.Equals(diagnostic.Id, DiagnosticIds.NE0001, StringComparison.Ordinal)) + .Where(diagnostic => fixAllContext.DiagnosticIds.Contains(diagnostic.Id)) .Where(diagnostic => diagnostic.Location.SourceTree is not null && solution.GetDocument(diagnostic.Location.SourceTree)?.Id == id @@ -184,9 +198,9 @@ diagnostic.Location.SourceTree is not null } // Registers the single-diagnostic fix and applies its first change. Returns null when no action is offered - // (the collision case, where the target file equals the current file), skipping without failing the batch. + // (e.g. NE0001's collision case, where the target file equals the current file), skipping without failing. private static async Task TryApplyFixAsync( - OneTypePerFileCodeFixProvider fixProvider, + CodeFixProvider fixProvider, Document document, Diagnostic diagnostic, CancellationToken cancellationToken diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs index b51f1ae..55b586f 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs @@ -15,9 +15,10 @@ namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; using NetEvolve.Analyzer.Maintainability; +using NetEvolve.Analyzer.Providers; /// -/// Drives end-to-end through a real , +/// Drives end-to-end through a real , /// mirroring but invoking the fix-all pipeline the IDE uses: it builds a project /// from named documents, constructs a for the requested /// backed by a diagnostic provider that runs , obtains the fix-all @@ -101,7 +102,8 @@ CancellationToken cancellationToken var project = solution.GetProject(projectId)!; var context = await CreateContextAsync(project, scope, cancellationToken).ConfigureAwait(false); - var action = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(context).ConfigureAwait(false); + var fixAllProvider = new OneTypePerFileCodeFixProvider().GetFixAllProvider()!; + var action = await fixAllProvider.GetFixAsync(context).ConfigureAwait(false); if (action is null) { return solution; @@ -129,7 +131,7 @@ CancellationToken cancellationToken project, fixProvider, scope, - nameof(OneTypePerFileFixAllProvider), + nameof(SequentialFixAllProvider), diagnosticIds, diagnosticProvider, cancellationToken @@ -141,7 +143,7 @@ CancellationToken cancellationToken trigger, fixProvider, scope, - nameof(OneTypePerFileFixAllProvider), + nameof(SequentialFixAllProvider), diagnosticIds, diagnosticProvider, cancellationToken diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs index 9ace509..0995e52 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs @@ -5,6 +5,7 @@ namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CodeFixes; using NetEvolve.Analyzer.Maintainability; +using NetEvolve.Analyzer.Providers; using TUnit.Assertions; using TUnit.Assertions.Extensions; using TUnit.Core; @@ -153,7 +154,7 @@ public async Task GetFixAsync_NullContext_ThrowsArgumentNullException() try { - _ = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(null!).ConfigureAwait(false); + _ = await new OneTypePerFileCodeFixProvider().GetFixAllProvider()!.GetFixAsync(null!).ConfigureAwait(false); } catch (ArgumentNullException exception) { @@ -183,7 +184,7 @@ public sealed class Circle { } [Test] public async Task GetSupportedFixAllScopes_AreDocumentProjectSolution() { - var scopes = OneTypePerFileFixAllProvider.Instance.GetSupportedFixAllScopes().ToList(); + var scopes = new OneTypePerFileCodeFixProvider().GetFixAllProvider()!.GetSupportedFixAllScopes().ToList(); await Assert.That(scopes).Contains(FixAllScope.Document); await Assert.That(scopes).Contains(FixAllScope.Project); @@ -195,6 +196,7 @@ public async Task GetFixAllProvider_ReturnsCustomProvider() { var provider = new OneTypePerFileCodeFixProvider().GetFixAllProvider(); - await Assert.That(provider).IsSameReferenceAs(OneTypePerFileFixAllProvider.Instance); + await Assert.That(provider).IsSameReferenceAs(new OneTypePerFileCodeFixProvider().GetFixAllProvider()); + await Assert.That(provider!.GetType()).IsEqualTo(typeof(SequentialFixAllProvider)); } } diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs index 0d64ceb..fd9b59c 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs @@ -3,6 +3,7 @@ namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; using System; using System.Threading.Tasks; using NetEvolve.Analyzer.Maintainability; +using NetEvolve.Analyzer.Providers; using TUnit.Assertions; using TUnit.Assertions.Extensions; using TUnit.Core; @@ -163,10 +164,11 @@ public async Task Nested_NoTrailingNewline_PreservesStyleAndUsesFolderNamespace( } [Test] - public async Task GetFixAllProvider_ReturnsNull() + public async Task GetFixAllProvider_ReturnsCustomProvider() { - var provider = new SingleNamespacePerFileCodeFixProvider(); + var provider = new SingleNamespacePerFileCodeFixProvider().GetFixAllProvider(); - await Assert.That(provider.GetFixAllProvider()).IsNull(); + await Assert.That(provider).IsSameReferenceAs(new SingleNamespacePerFileCodeFixProvider().GetFixAllProvider()); + await Assert.That(provider!.GetType()).IsEqualTo(typeof(SequentialFixAllProvider)); } } diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs index 8ee1932..80a9e8c 100644 --- a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs @@ -5,7 +5,6 @@ namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; using System.Collections.Immutable; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -16,9 +15,10 @@ namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; using Microsoft.CodeAnalysis.Text; using NetEvolve.Analyzer; using NetEvolve.Analyzer.Maintainability; +using NetEvolve.Analyzer.Providers; /// -/// Drives end-to-end through a real from +/// Drives end-to-end through a real from /// the unit suite as well, so the fix-all pipeline 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). It builds a project from named /// documents, constructs a for the requested backed by a @@ -85,7 +85,8 @@ CancellationToken cancellationToken var project = solution.GetProject(projectId)!; var context = await CreateContextAsync(project, scope, cancellationToken).ConfigureAwait(false); - var action = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(context).ConfigureAwait(false); + var fixAllProvider = new OneTypePerFileCodeFixProvider().GetFixAllProvider()!; + var action = await fixAllProvider.GetFixAsync(context).ConfigureAwait(false); if (action is null) { return solution; @@ -111,7 +112,7 @@ CancellationToken cancellationToken project, fixProvider, scope, - nameof(OneTypePerFileFixAllProvider), + nameof(SequentialFixAllProvider), diagnosticIds, diagnosticProvider, cancellationToken @@ -123,7 +124,7 @@ CancellationToken cancellationToken trigger, fixProvider, scope, - nameof(OneTypePerFileFixAllProvider), + nameof(SequentialFixAllProvider), diagnosticIds, diagnosticProvider, cancellationToken diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs index 672e581..da9d86a 100644 --- a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs @@ -76,6 +76,45 @@ out var expected await Assert.That(expected).IsEqualTo("Geometry.Shapes.Primitives"); } + [Test] + public async Task TryResolve_EmptyRootNamespace_SubFolders_JoinsSegmentsAlone() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", ""), ("ProjectDir", "/proj")), + "/proj/Shapes/Primitives/Circle.cs", + out var expected + ); + + await Assert.That(resolved).IsTrue(); + await Assert.That(expected).IsEqualTo("Shapes.Primitives"); + } + + [Test] + public async Task TryResolve_MissingRootNamespace_SubFolders_JoinsSegmentsAlone() + { + var resolved = FolderNamespace.TryResolve( + Options(("ProjectDir", "/proj")), + "/proj/Shapes/Circle.cs", + out var expected + ); + + await Assert.That(resolved).IsTrue(); + await Assert.That(expected).IsEqualTo("Shapes"); + } + + [Test] + public async Task TryResolve_EmptyRootNamespace_ProjectRoot_ReturnsFalse() + { + // No folders to compose from and no RootNamespace anchor: nothing reliable to map to, so stay silent. + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", ""), ("ProjectDir", "/proj")), + "/proj/Circle.cs", + out _ + ); + + await Assert.That(resolved).IsFalse(); + } + [Test] public async Task TryResolve_OutsideProjectDir_ReturnsFalse() { diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs index 465ad37..21b84b3 100644 --- a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs @@ -5,6 +5,7 @@ namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CodeFixes; using NetEvolve.Analyzer.Maintainability; +using NetEvolve.Analyzer.Providers; using TUnit.Assertions; using TUnit.Assertions.Extensions; using TUnit.Core; @@ -123,7 +124,7 @@ public sealed class Item { } [Test] public async Task GetSupportedFixAllScopes_AreDocumentProjectSolution() { - var scopes = OneTypePerFileFixAllProvider.Instance.GetSupportedFixAllScopes().ToList(); + var scopes = new OneTypePerFileCodeFixProvider().GetFixAllProvider()!.GetSupportedFixAllScopes().ToList(); await Assert.That(scopes).Contains(FixAllScope.Document); await Assert.That(scopes).Contains(FixAllScope.Project); @@ -135,7 +136,8 @@ public async Task GetFixAllProvider_ReturnsCustomProvider() { var provider = new OneTypePerFileCodeFixProvider().GetFixAllProvider(); - await Assert.That(provider).IsSameReferenceAs(OneTypePerFileFixAllProvider.Instance); + await Assert.That(provider).IsSameReferenceAs(new OneTypePerFileCodeFixProvider().GetFixAllProvider()); + await Assert.That(provider!.GetType()).IsEqualTo(typeof(SequentialFixAllProvider)); } [Test] @@ -145,7 +147,7 @@ public async Task GetFixAsync_NullContext_ThrowsArgumentNullException() try { - _ = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(null!).ConfigureAwait(false); + _ = await new OneTypePerFileCodeFixProvider().GetFixAllProvider()!.GetFixAsync(null!).ConfigureAwait(false); } catch (ArgumentNullException exception) {