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
67 changes: 67 additions & 0 deletions docs/rules/NE0002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# NE0002: Namespace should match the folder structure

| Property | Value |
|------------|------------------|
| Rule ID | NE0002 |
| Category | Maintainability |
| Severity | Warning |
| Code fix | Yes |

## Cause

A file's top-level namespace declaration does not match the namespace derived from the file's folder path,
anchored at the `RootNamespace` MSBuild property. The expected namespace is `RootNamespace` joined with the
file's folder path relative to the project directory (`ProjectDir`).

## Rule description

Keeping the declared namespace aligned with the folder layout makes types predictable to locate and keeps the
physical and logical structure in lockstep.

- The expected namespace is `RootNamespace` + the folder path (relative to `ProjectDir`), dot-joined. A file
at `<ProjectDir>/Shapes/Primitives/Circle.cs` with `RootNamespace` `Geometry` maps to
`Geometry.Shapes.Primitives`.
- A file directly in the project directory maps to `RootNamespace` exactly.
- Only the **top-level** namespace declaration is considered; nested namespaces are the concern of
[NE0003](NE0003.md).
- Files with no namespace declaration (the global namespace) are out of scope and never flagged.
- Generated code is skipped.

### Open questions from the issue

- **`RootNamespace` is required.** Without both `RootNamespace` and `ProjectDir` no reliable mapping exists,
so the rule stays silent (they are supplied by the SDK for normal builds).
- **Non-identifier folder segments are skipped.** When a folder segment is not a valid C# identifier
(for example, it starts with a digit), no namespace can be derived from it and the file is not flagged.
- **Files outside the project directory** have no folder-to-namespace mapping and are not flagged.

## How to fix violations

Rename the declared namespace to the folder-derived value, or move the file into the folder that matches its
namespace. A code fix is provided:

- **Change namespace to '`Expected.Namespace`'** — rewrites the top-level namespace declaration's name to the
folder-derived namespace. The file is not moved.

## Configuration

The rule anchors on the standard MSBuild properties, which are compiler-visible by default:

```xml
<PropertyGroup>
<!-- The namespace anchor; the folder-derived namespace is RootNamespace + relative folder path. -->
<RootNamespace>Geometry</RootNamespace>

<!-- 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 NE0002
#pragma warning restore NE0002
```
1 change: 1 addition & 0 deletions src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,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)
10 changes: 10 additions & 0 deletions src/NetEvolve.Analyzer/BuildProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ internal static class BuildProperty
/// a single file named after the base identifier.
/// </summary>
public const string GroupGenericOverloads = Prefix + "NetEvolveAnalyzerGroupGenericOverloads";

/// <summary>
/// <c>RootNamespace</c> — the namespace anchor NE0002 uses as the root of the folder-derived namespace.
/// </summary>
public const string RootNamespace = Prefix + "RootNamespace";

/// <summary>
/// <c>ProjectDir</c> — the project directory NE0002 measures a file's folder path against.
/// </summary>
public const string ProjectDir = Prefix + "ProjectDir";
}
14 changes: 14 additions & 0 deletions src/NetEvolve.Analyzer/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,18 @@ internal static class DiagnosticDescriptors
+ "overloads are encoded by arity unless overload grouping is enabled.",
helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0001)
);

/// <summary>NE0002 — the declared namespace should match the folder structure relative to <c>RootNamespace</c>.</summary>
public static readonly DiagnosticDescriptor NamespaceMatchesFolder = new(
id: DiagnosticIds.NE0002,
title: "Namespace should match the folder structure",
messageFormat: "Namespace '{0}' should be '{1}' to match the folder structure",
category: DiagnosticCategories.Maintainability,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Anchored at the RootNamespace MSBuild property, the declared namespace should equal "
+ "RootNamespace joined with the file's folder path relative to the project directory, so the "
+ "physical and logical layout stay aligned.",
helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0002)
);
}
5 changes: 5 additions & 0 deletions src/NetEvolve.Analyzer/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ internal static class DiagnosticIds
/// </summary>
public const string NE0001 = Prefix + "0001";

/// <summary>
/// NE0002 — the declared namespace should match the folder structure, anchored at <c>RootNamespace</c>.
/// </summary>
public const string NE0002 = Prefix + "0002";

/// <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
113 changes: 113 additions & 0 deletions src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
namespace NetEvolve.Analyzer.Maintainability;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

/// <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.
/// </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.
/// </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>
/// <param name="expected">The folder-derived namespace when the method returns <see langword="true"/>.</param>
public static bool TryResolve(AnalyzerConfigOptions globalOptions, string filePath, out string expected)
{
expected = string.Empty;

if (string.IsNullOrEmpty(filePath))
{
return false;
}

if (
!TryGetNonEmpty(globalOptions, BuildProperty.RootNamespace, out var rootNamespace)
|| !TryGetNonEmpty(globalOptions, BuildProperty.ProjectDir, out var projectDir)
)
{
return false;
}

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;
}

if (!TryGetRelativeSegments(projectDir, directory!, out var segments))
{
return false;
}

if (segments.Count == 0)
{
// The file sits directly in the project directory: it maps to the root namespace exactly.
expected = rootNamespace;
return true;
}

if (segments.Any(segment => !SyntaxFacts.IsValidIdentifier(segment)))
{
return false;
}

expected = rootNamespace + "." + string.Join(".", segments);
return true;
}

private static bool TryGetNonEmpty(AnalyzerConfigOptions options, string key, out string value)
{
if (options.TryGetValue(key, out var raw) && !string.IsNullOrEmpty(raw))
{
value = raw;
return true;
}

value = string.Empty;
return false;
}

private static bool TryGetRelativeSegments(string projectDir, string directory, out List<string> segments)
{
segments = new List<string>();

var root = Normalize(projectDir);
var target = Normalize(directory);

if (string.Equals(root, target, StringComparison.OrdinalIgnoreCase))
{
return true;
}

var prefix = root + "/";
if (!target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
// The file lives outside the project directory: no reliable folder-to-namespace mapping.
return false;
}

segments = target
.Substring(prefix.Length)
.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries)
.ToList();
return true;
}

private static string Normalize(string path) => path.Replace('\\', '/').TrimEnd('/');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace NetEvolve.Analyzer.Maintainability;

using System;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

/// <summary>
/// NE0002 — reports when a file's top-level namespace declaration does not match the folder-derived namespace,
/// anchored at the <c>RootNamespace</c> MSBuild property. The expected value is <c>RootNamespace</c> joined with
/// the file's folder path relative to the project directory (see <see cref="FolderNamespace"/>). Files without a
/// namespace (the global namespace) and nested namespace declarations are out of scope.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NamespaceMatchesFolderAnalyzer : DiagnosticAnalyzer
{
/// <summary>Diagnostic property key carrying the expected folder-derived namespace.</summary>
internal const string ExpectedNamespaceProperty = "ExpectedNamespace";

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

/// <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 filePath = context.Tree.FilePath;
if (string.IsNullOrEmpty(filePath))
{
return;
}

var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
if (
GetBoolean(globalOptions, BuildProperty.DisableFileOrganizationRules)
|| GetBoolean(globalOptions, BuildProperty.PublishSingleFile)
)
{
return;
}

if (!FolderNamespace.TryResolve(globalOptions, filePath, out var expected))
{
return;
}

var root = context.Tree.GetRoot(context.CancellationToken);

// NE0002 evaluates only namespaces declared directly under the compilation unit. Nested namespaces
// are left to NE0003, and source in the global namespace is out of scope.
foreach (var node in root.ChildNodes())
{
if (node is not BaseNamespaceDeclarationSyntax declaration)
{
continue;
}

var actual = declaration.Name.ToString();
if (string.Equals(actual, expected, StringComparison.Ordinal))
{
continue;
}

// Surface the expected namespace so the code fix can rewrite the name without re-deriving it.
var properties = ImmutableDictionary<string, string?>.Empty.Add(ExpectedNamespaceProperty, expected);

context.ReportDiagnostic(
Diagnostic.Create(
DiagnosticDescriptors.NamespaceMatchesFolder,
declaration.Name.GetLocation(),
properties,
actual,
expected
)
);
}
}

private static bool GetBoolean(AnalyzerConfigOptions options, string key) =>
options.TryGetValue(key, out var value) && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
Loading