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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/NetEvolve.Analyzer/Builders/NamespaceFileBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal static class NamespaceFileBuilder
{
/// <summary>
/// Assembles the new file as text: the file-level usings, an optional file-scoped namespace with the FULL
/// dotted <paramref name="namespaceName"/> (omitted when empty), then every member in
/// <paramref name="members"/> rendered from its full text so leading doc comments travel with it.
/// </summary>
public static string Build(
CompilationUnitSyntax root,
string namespaceName,
IReadOnlyList<MemberDeclarationSyntax> 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();
}

/// <summary>
/// Preserves the original file's final-newline style: trims trailing blank lines left by an edit, then
/// re-adds a single newline only when <paramref name="trailingNewline"/> is <see langword="true"/>.
/// </summary>
public static string WithTrailingNewline(string text, bool trailingNewline) =>
trailingNewline ? text.TrimEnd() + "\n" : text.TrimEnd();

/// <summary>The top-level type declarations (block- or file-scoped) of <paramref name="root"/>.</summary>
public static IEnumerable<MemberDeclarationSyntax> TopLevelTypeDeclarations(CompilationUnitSyntax root) =>
root.DescendantNodes().Where(IsTopLevelTypeDeclaration).Cast<MemberDeclarationSyntax>();

/// <summary>
/// Whether <paramref name="node"/> is a top-level type declaration — a type or delegate declared directly
/// under a namespace or the compilation unit.
/// </summary>
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())
);
}
}
49 changes: 33 additions & 16 deletions src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ namespace NetEvolve.Analyzer.Maintainability;

/// <summary>
/// Computes the namespace a file should declare from its location relative to the project directory, anchored
/// at the <c>RootNamespace</c> MSBuild property. Shared by <c>NamespaceMatchesFolderAnalyzer</c> (NE0002) and
/// the NE0003 nested-namespace flatten fix, so both derive the same folder-anchored value.
/// at the <c>RootNamespace</c> MSBuild property. When <c>RootNamespace</c> is absent or empty the namespace is
/// composed purely from the folder segments below the project directory. Shared by
/// <c>NamespaceMatchesFolderAnalyzer</c> (NE0002) and the NE0003 nested-namespace flatten fix, so both derive
/// the same folder-anchored value.
/// </summary>
internal static class FolderNamespace
{
private static readonly char[] PathSeparators = { '/' };

/// <summary>
/// Resolves the folder-derived namespace for <paramref name="filePath"/>. Returns <see langword="false"/>
/// when the anchor properties (<c>RootNamespace</c>, <c>ProjectDir</c>) 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 <c>ProjectDir</c> 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 <c>RootNamespace</c> anchor — in all
/// of which cases no reliable mapping exists and the caller should stay silent. <c>RootNamespace</c> is
/// optional: when it is absent or empty the returned namespace is the folder segments joined on their own.
/// </summary>
/// <param name="globalOptions">The global analyzer-config options exposing the build properties.</param>
/// <param name="filePath">The absolute (or project-relative) path of the source file.</param>
Expand All @@ -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))
Expand All @@ -57,17 +62,29 @@ 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)))
{
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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
namespace NetEvolve.Analyzer.Maintainability;
namespace NetEvolve.Analyzer.Maintainability;

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
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;

/// <summary>
/// Code fix for <see cref="OneTypePerFileAnalyzer">NE0001</see>. Offers to rename the file to match its single
Expand All @@ -25,13 +25,18 @@ namespace NetEvolve.Analyzer.Maintainability;
[Shared]
public sealed class OneTypePerFileCodeFixProvider : CodeFixProvider
{
private static readonly Lazy<SequentialFixAllProvider> FixAll = new(
() => new SequentialFixAllProvider(() => new OneTypePerFileAnalyzer()),
LazyThreadSafetyMode.ExecutionAndPublication
);

/// <inheritdoc />
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0001);

/// <inheritdoc />
// 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;

/// <inheritdoc />
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -135,62 +143,6 @@ CancellationToken cancellationToken
);
}

private static string BuildNewFileText(
CompilationUnitSyntax root,
string namespaceName,
IReadOnlyList<MemberDeclarationSyntax> 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<MemberDeclarationSyntax> MatchingDeclarations(
CompilationUnitSyntax root,
MemberDeclarationSyntax declaration,
Expand All @@ -201,9 +153,8 @@ bool groupGenericOverloads
var arity = Arity(declaration);
var @namespace = NamespaceName(declaration);

return root.DescendantNodes()
.Where(IsTopLevelTypeDeclaration)
.Cast<MemberDeclarationSyntax>()
return NamespaceFileBuilder
.TopLevelTypeDeclarations(root)
.Where(member =>
string.Equals(Identifier(member).ValueText, name, StringComparison.Ordinal)
&& string.Equals(NamespaceName(member), @namespace, StringComparison.Ordinal)
Expand All @@ -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;

Expand Down
Loading