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
54 changes: 54 additions & 0 deletions docs/rules/NE0001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# NE0001: Declare one type per file with a matching file name

| Property | Value |
|------------|------------------|
| Rule ID | NE0001 |
| Category | Maintainability |
| Severity | Warning |
| Code fix | Not yet (planned)|

## Cause

A file declares more than one top-level type, or its single top-level type has a name that does not match
the file name. Top-level types are `class`, `struct`, `record`, `record struct`, `interface`, `enum` and
`delegate` declarations that are not nested inside another type.

## Rule description

Keeping one type per file — with the file named after the type — makes types predictable to locate and
keeps diffs small.

- One file declares exactly one top-level type.
- The file name equals the type name: `TypeName` ⇒ `TypeName.cs`.
- **Generic overloads.** By default, overloads that share a base name (`Result`, `Result<T>`,
`Result<T1, T2>`) are distinct types, each in its own arity-encoded file (`Result.cs`, `Result{T}.cs`,
`Result{T1,T2}.cs`). Set `NetEvolveAnalyzerGroupGenericOverloads` to `true` to let them share one file
named after the base identifier (`Result.cs`).
- `partial` parts of the same type in one file count as a single type.
- Nested types are ignored; only top-level declarations are considered.
- Generated code is skipped.

## How to fix violations

Move each extra type into its own file, and rename files so the name matches the contained type.

## Configuration

```xml
<PropertyGroup>
<!-- Allow grouping generic overloads (Result, Result<T>, ...) in one file named 'Result.cs'. -->
<NetEvolveAnalyzerGroupGenericOverloads>true</NetEvolveAnalyzerGroupGenericOverloads>

<!-- 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 NE0001
#pragma warning restore NE0001
```
1 change: 0 additions & 1 deletion src/NetEvolve.Analyzer/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
re
1 change: 1 addition & 0 deletions src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
NE0001 | Maintainability | Warning | OneTypePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0001.md)
7 changes: 7 additions & 0 deletions src/NetEvolve.Analyzer/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ internal static class DiagnosticIds
/// </summary>
private const string HelpLinkBase = "https://github.com/dailydevops/analyzer/blob/main/docs/rules/";

// Maintainability

/// <summary>
/// NE0001 — each file should declare a single top-level type whose name matches the file name.
/// </summary>
public const string NE0001 = Prefix + "0001";

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

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

/// <summary>
/// NE0001 — reports when a file declares more than one top-level type, or when its single top-level type
/// does not match the file name. Generic overloads that share a base name (<c>Result</c>, <c>Result&lt;T&gt;</c>)
/// are, by default, treated as distinct types encoded by arity (<c>Result{T}.cs</c>); enabling
/// <c>NetEvolveAnalyzerGroupGenericOverloads</c> lets them share a single file named after the base identifier.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class OneTypePerFileAnalyzer : DiagnosticAnalyzer
{
private const string GroupGenericOverloadsProperty = "build_property.NetEvolveAnalyzerGroupGenericOverloads";
private const string DisableProperty = "build_property.NetEvolveAnalyzerDisableFileOrganizationRules";
private const string PublishSingleFileProperty = "build_property.PublishSingleFile";

/// <summary>The descriptor for NE0001.</summary>
internal static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticIds.NE0001,
title: "Declare one type per file with a matching file name",
messageFormat: "Type '{0}' should be declared in its own file named '{1}.cs'",
category: DiagnosticCategories.Maintainability,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Each top-level type should live in its own file whose name matches the type. Generic "
+ "overloads are encoded by arity unless overload grouping is enabled.",
helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0001)
);

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

/// <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, DisableProperty) || GetBoolean(globalOptions, PublishSingleFileProperty))
{
return;
}

var groupGenericOverloads = GetBoolean(globalOptions, GroupGenericOverloadsProperty);
var root = context.Tree.GetRoot(context.CancellationToken);

var groups = GroupTopLevelTypes(root, groupGenericOverloads);
if (groups.Count == 0)
{
return;
}

var fileName = Path.GetFileNameWithoutExtension(filePath);
var primary = groups.FirstOrDefault(group =>
string.Equals(group.ExpectedFileName, fileName, StringComparison.Ordinal)
);

foreach (var group in groups)
{
if (ReferenceEquals(group, primary))
{
continue;
}

context.ReportDiagnostic(
Diagnostic.Create(
Rule,
group.First.Identifier.GetLocation(),
group.First.Display,
group.ExpectedFileName
)
);
}
}

private static List<TypeGroup> GroupTopLevelTypes(SyntaxNode root, bool groupGenericOverloads)
{
var groups = new List<TypeGroup>();
var index = new Dictionary<string, TypeGroup>(StringComparer.Ordinal);

foreach (var node in root.DescendantNodes().Where(IsTopLevelTypeDeclaration))
{
var type = TypeDescriptor.From(node);

// The identity key is scoped by namespace so that only genuine partial parts (same namespace,
// name and arity) collapse into one group; two distinct same-named types in different namespaces
// remain separate types and are each evaluated.
var identity = groupGenericOverloads ? type.Name : type.MetadataName;
var key = GetNamespaceName(node) + "::" + identity;
if (!index.TryGetValue(key, out var group))
{
group = new TypeGroup(type, groupGenericOverloads);
index.Add(key, group);
groups.Add(group);
}
}

return groups;
}

private static string GetNamespaceName(SyntaxNode node)
{
var segments = new List<string>();
for (var current = node.Parent; current is not null; current = current.Parent)
{
if (current is BaseNamespaceDeclarationSyntax namespaceDeclaration)
{
segments.Add(namespaceDeclaration.Name.ToString());
}
}

segments.Reverse();
return string.Join(".", segments);
}

private static bool IsTopLevelTypeDeclaration(SyntaxNode node) =>
node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax
&& node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax;

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

/// <summary>A single top-level type declaration reduced to the facts NE0001 needs.</summary>
private readonly struct TypeDescriptor
{
private TypeDescriptor(SyntaxToken identifier, string name, ImmutableArray<string> typeParameters)
{
Identifier = identifier;
Name = name;
TypeParameters = typeParameters;
}

public SyntaxToken Identifier { get; }

public string Name { get; }

public ImmutableArray<string> TypeParameters { get; }

public string MetadataName =>
TypeParameters.IsEmpty ? Name : Name + "`" + TypeParameters.Length.ToString(CultureInfo.InvariantCulture);

public string Display => TypeParameters.IsEmpty ? Name : Name + "<" + string.Join(", ", TypeParameters) + ">";

public string ArityEncodedFileName =>
TypeParameters.IsEmpty ? Name : Name + "{" + string.Join(",", TypeParameters) + "}";

public static TypeDescriptor From(SyntaxNode node)
{
if (node is TypeDeclarationSyntax type)
{
return new TypeDescriptor(
type.Identifier,
type.Identifier.ValueText,
GetTypeParameters(type.TypeParameterList)
);
}

if (node is DelegateDeclarationSyntax @delegate)
{
return new TypeDescriptor(
@delegate.Identifier,
@delegate.Identifier.ValueText,
GetTypeParameters(@delegate.TypeParameterList)
);
}

var @enum = (EnumDeclarationSyntax)node;
return new TypeDescriptor(@enum.Identifier, @enum.Identifier.ValueText, ImmutableArray<string>.Empty);
}

private static ImmutableArray<string> GetTypeParameters(TypeParameterListSyntax? list) =>
list is null
? ImmutableArray<string>.Empty
: list.Parameters.Select(parameter => parameter.Identifier.ValueText).ToImmutableArray();
}

/// <summary>All declarations that share one type identity (partial parts, or grouped generic overloads).</summary>
private sealed class TypeGroup
{
public TypeGroup(TypeDescriptor first, bool groupGenericOverloads)
{
First = first;
ExpectedFileName = groupGenericOverloads ? first.Name : first.ArityEncodedFileName;
}

public TypeDescriptor First { get; }

public string ExpectedFileName { get; }
}
}
5 changes: 5 additions & 0 deletions src/NetEvolve.Analyzer/NetEvolve.Analyzer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>

<ItemGroup Label="Consumer MSBuild props (exposes CompilerVisibleProperty for the rules)">
<!-- Must be named <PackageId>.props and ship under build/ so it auto-imports into consuming projects. -->
<None Include="build\NetEvolve.Analyzer.props" Pack="true" PackagePath="build\NetEvolve.Analyzer.props" />
</ItemGroup>

<!-- Place the built assembly at exactly analyzers/dotnet/cs (no TFM subfolder), so Roslyn discovers it.
Runs during pack, after the assembly has been produced. -->
<Target Name="_PackAnalyzer">
Expand Down
11 changes: 11 additions & 0 deletions src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>
<!--
Exposes the MSBuild properties that the file/namespace organization rules (NE0001+) read through
Roslyn's AnalyzerConfigOptions. Without CompilerVisibleProperty these values never reach the analyzer.
-->
<ItemGroup>
<CompilerVisibleProperty Include="PublishSingleFile" />
<CompilerVisibleProperty Include="NetEvolveAnalyzerDisableFileOrganizationRules" />
<CompilerVisibleProperty Include="NetEvolveAnalyzerGroupGenericOverloads" />
</ItemGroup>
</Project>
16 changes: 16 additions & 0 deletions test/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project>
<!-- Chain to the repository-root Directory.Build.props (a nested file otherwise stops the search). -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<PropertyGroup>
<!--
Test-only rule adjustments live here: nested .editorconfig files are git-ignored (.gitignore) and the
root .editorconfig is template-managed ("DO NOT CHANGE SETTINGS IN THIS FILE"), so a project-scoped
NoWarn is the committed, policy-safe place for them.

IDE0058 (unused expression value): TUnit fluent assertions are awaited as expression statements, e.g.
`await Assert.That(x).IsEqualTo(y);` — the awaited builder returns a value by design.
-->
<NoWarn>$(NoWarn);IDE0058</NoWarn>
</PropertyGroup>
</Project>
Loading
Loading