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
55 changes: 55 additions & 0 deletions docs/rules/NE0003.md
Original file line number Diff line number Diff line change
@@ -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
<PropertyGroup>
<!-- Turn the file/namespace organization rules off entirely. -->
<NetEvolveAnalyzerDisableFileOrganizationRules>true</NetEvolveAnalyzerDisableFileOrganizationRules>
</PropertyGroup>
```

The rules are also disabled automatically for single-file deployments (`PublishSingleFile=true`).

## Suppress a warning

```csharp
#pragma warning disable NE0003
#pragma warning restore NE0003
```
1 change: 1 addition & 0 deletions src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 14 additions & 0 deletions src/NetEvolve.Analyzer/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,18 @@ internal static class DiagnosticDescriptors
+ "physical and logical layout stay aligned.",
helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0002)
);

/// <summary>NE0003 — a file should declare exactly one namespace.</summary>
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)
);
}
5 changes: 5 additions & 0 deletions src/NetEvolve.Analyzer/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ internal static class DiagnosticIds
/// </summary>
public const string NE0002 = Prefix + "0002";

/// <summary>
/// NE0003 — a file should declare exactly one namespace.
/// </summary>
public const string NE0003 = Prefix + "0003";

/// <summary>Builds the documentation help link for a diagnostic identifier.</summary>
/// <param name="diagnosticId">The diagnostic identifier, e.g. <c>NE0001</c>.</param>
/// <returns>An absolute URI pointing at the rule's documentation.</returns>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SingleNamespacePerFileAnalyzer : DiagnosticAnalyzer
{
/// <summary>Diagnostic property key: <c>"true"</c> when the flagged namespace is nested inside another.</summary>
internal const string NestedProperty = "Nested";

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(DiagnosticDescriptors.SingleNamespacePerFile);

/// <inheritdoc />
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<BaseNamespaceDeclarationSyntax>().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<BaseNamespaceDeclarationSyntax>().Any();
var value = nested ? "true" : "false";
var properties = ImmutableDictionary<string, string?>.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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Code fix for <see cref="SingleNamespacePerFileAnalyzer">NE0003</see>. Offered only for the nested shape
/// (<c>Nested == "true"</c>): 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.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SingleNamespacePerFileCodeFixProvider))]
[Shared]
public sealed class SingleNamespacePerFileCodeFixProvider : CodeFixProvider
{
/// <inheritdoc />
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0003);

/// <inheritdoc />
// A whole-file rewrite does not compose safely across many diagnostics, so no batch fix-all.
public override FixAllProvider? GetFixAllProvider() => null;

/// <inheritdoc />
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<Document> 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<MemberDeclarationSyntax>();
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<string>();
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);
}
}
Loading