Skip to content

Commit

Permalink
Analyzer: Add/remove blank line after file scoped namespace (dotnet#993)
Browse files Browse the repository at this point in the history
  • Loading branch information
josefpihrt authored and JochemHarmes committed Oct 30, 2023
1 parent 23e81b1 commit 8320d2a
Show file tree
Hide file tree
Showing 12 changed files with 540 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .editorconfig
Expand Up @@ -35,6 +35,7 @@ roslynator_use_var_instead_of_implicit_object_creation = true
roslynator_infinite_loop_style = while
roslynator_doc_comment_summary_style = multi_line
roslynator_enum_flag_value_style = shift_operator
roslynator_blank_line_after_file_scoped_namespace_declaration = true

csharp_style_namespace_declarations = file_scoped:suggestion

Expand Down Expand Up @@ -77,6 +78,7 @@ dotnet_diagnostic.RCS0055.severity = suggestion
dotnet_diagnostic.RCS0057.severity = suggestion
dotnet_diagnostic.RCS0058.severity = suggestion
dotnet_diagnostic.RCS0059.severity = suggestion
dotnet_diagnostic.RCS0060.severity = suggestion
dotnet_diagnostic.RCS1002.severity = silent
dotnet_diagnostic.RCS1006.severity = suggestion
dotnet_diagnostic.RCS1008.severity = suggestion
Expand Down
2 changes: 2 additions & 0 deletions ChangeLog.md
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add Arm64 VS 2022 extension support ([#990](https://github.com/JosefPihrt/Roslynator/pull/990) by @snickler).
- Add analyzer "Add/remove blank line after file scoped namespace declaration" [RCS0060](https://github.com/JosefPihrt/Roslynator/blob/main/docs/analyzers/RCS0060.md) ([#993](https://github.com/josefpihrt/roslynator/pull/993).
- Required option: `roslynator_blank_line_after_file_scoped_namespace_declaration = true|false`

### Changed

Expand Down
10 changes: 10 additions & 0 deletions src/Common/CSharp/Extensions/CodeStyleExtensions.cs
Expand Up @@ -495,6 +495,16 @@ public static BodyStyle GetBodyStyle(this SyntaxNodeAnalysisContext context)
return null;
}

public static BlankLineStyle GetBlankLineAfterFileScopedNamespaceDeclaration(this SyntaxNodeAnalysisContext context)
{
if (ConfigOptions.TryGetValueAsBool(context.GetConfigOptions(), ConfigOptions.BlankLineAfterFileScopedNamespaceDeclaration, out bool value))
{
return (value) ? BlankLineStyle.Add : BlankLineStyle.Remove;
}

return BlankLineStyle.None;
}

public static bool? GetSuppressUnityScriptMethods(this SyntaxNodeAnalysisContext context)
{
if (ConfigOptions.TryGetValueAsBool(context.GetConfigOptions(), ConfigOptions.SuppressUnityScriptMethods, out bool value))
Expand Down
1 change: 1 addition & 0 deletions src/Common/ConfigOptionKeys.Generated.cs
Expand Up @@ -11,6 +11,7 @@ internal static partial class ConfigOptionKeys
public const string ArrayCreationTypeStyle = "roslynator_array_creation_type_style";
public const string ArrowTokenNewLine = "roslynator_arrow_token_new_line";
public const string BinaryOperatorNewLine = "roslynator_binary_operator_new_line";
public const string BlankLineAfterFileScopedNamespaceDeclaration = "roslynator_blank_line_after_file_scoped_namespace_declaration";
public const string BlankLineBetweenClosingBraceAndSwitchSection = "roslynator_blank_line_between_closing_brace_and_switch_section";
public const string BlankLineBetweenSingleLineAccessors = "roslynator_blank_line_between_single_line_accessors";
public const string BlankLineBetweenUsingDirectives = "roslynator_blank_line_between_using_directives";
Expand Down
7 changes: 7 additions & 0 deletions src/Common/ConfigOptions.Generated.cs
Expand Up @@ -38,6 +38,12 @@ public static partial class ConfigOptions
defaultValuePlaceholder: "after|before",
description: "Place new line after/before binary operator");

public static readonly ConfigOptionDescriptor BlankLineAfterFileScopedNamespaceDeclaration = new(
key: ConfigOptionKeys.BlankLineAfterFileScopedNamespaceDeclaration,
defaultValue: null,
defaultValuePlaceholder: "true|false",
description: "Add/remove blank line after file scoped namespace declaration");

public static readonly ConfigOptionDescriptor BlankLineBetweenClosingBraceAndSwitchSection = new(
key: ConfigOptionKeys.BlankLineBetweenClosingBraceAndSwitchSection,
defaultValue: null,
Expand Down Expand Up @@ -213,6 +219,7 @@ public static partial class ConfigOptions
yield return new KeyValuePair<string, string>("RCS0052", JoinOptionKeys(ConfigOptionKeys.EqualsTokenNewLine));
yield return new KeyValuePair<string, string>("RCS0058", JoinOptionKeys(ConfigOptionKeys.NewLineAtEndOfFile));
yield return new KeyValuePair<string, string>("RCS0059", JoinOptionKeys(ConfigOptionKeys.NullConditionalOperatorNewLine));
yield return new KeyValuePair<string, string>("RCS0060", JoinOptionKeys(ConfigOptionKeys.BlankLineAfterFileScopedNamespaceDeclaration));
yield return new KeyValuePair<string, string>("RCS1014", JoinOptionKeys(ConfigOptionKeys.ArrayCreationTypeStyle));
yield return new KeyValuePair<string, string>("RCS1016", JoinOptionKeys(ConfigOptionKeys.BodyStyle, ConfigOptionKeys.UseBlockBodyWhenDeclarationSpansOverMultipleLines, ConfigOptionKeys.UseBlockBodyWhenExpressionSpansOverMultipleLines));
yield return new KeyValuePair<string, string>("RCS1018", JoinOptionKeys(ConfigOptionKeys.AccessibilityModifiers));
Expand Down
4 changes: 4 additions & 0 deletions src/Common/ConfigOptions.xml
Expand Up @@ -195,6 +195,10 @@
</Values>
<Description>Use 'for'/'while' statement as an infinite loop</Description>
</Option>
<Option Id="BlankLineAfterFileScopedNamespaceDeclaration">
<ValuePlaceholder>true|false</ValuePlaceholder>
<Description>Add/remove blank line after file scoped namespace declaration</Description>
</Option>
<!--
<Option Id="">
<Key></Key>
Expand Down
@@ -0,0 +1,76 @@
// Copyright (c) Josef Pihrt and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Immutable;
using System.Composition;
using System.Linq;
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 Roslynator.CSharp;
using Roslynator.Formatting.CSharp;
using Roslynator.CSharp.CodeStyle;
using System.Diagnostics;

namespace Roslynator.Formatting.CodeFixes.CSharp;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FileScopedNamespaceDeclarationCodeFixProvider))]
[Shared]
public sealed class FileScopedNamespaceDeclarationCodeFixProvider : BaseCodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds
{
get { return ImmutableArray.Create(DiagnosticIdentifiers.BlankLineAfterFileScopedNamespaceDeclaration); }
}

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
SyntaxNode root = await context.GetSyntaxRootAsync().ConfigureAwait(false);

if (!TryFindFirstAncestorOrSelf(root, context.Span, out FileScopedNamespaceDeclarationSyntax fileScopedNamespace))
return;

Document document = context.Document;
Diagnostic diagnostic = context.Diagnostics[0];
MemberDeclarationSyntax member = fileScopedNamespace.Members[0];
BlankLineStyle style = BlankLineAfterFileScopedNamespaceDeclarationAnalyzer.GetCurrentStyle(fileScopedNamespace, member);

if (style == BlankLineStyle.Add)
{
CodeAction codeAction = CodeAction.Create(
CodeFixTitles.AddBlankLine,
ct =>
{
MemberDeclarationSyntax newMember;
if (!fileScopedNamespace.SemicolonToken.TrailingTrivia.Contains(SyntaxKind.EndOfLineTrivia))
{
newMember = member.PrependToLeadingTrivia(new SyntaxTrivia[] { CSharpFactory.NewLine(), CSharpFactory.NewLine() });
}
else
{
newMember = member.PrependEndOfLineToLeadingTrivia();
}
return document.ReplaceNodeAsync(member, newMember, ct);
},
GetEquivalenceKey(diagnostic));

context.RegisterCodeFix(codeAction, diagnostic);
}
else if (style == BlankLineStyle.Remove)
{
CodeAction codeAction = CodeAction.Create(
CodeFixTitles.RemoveBlankLine,
ct => CodeFixHelpers.RemoveBlankLinesBeforeAsync(document, member.GetFirstToken(), ct),
GetEquivalenceKey(diagnostic));

context.RegisterCodeFix(codeAction, diagnostic);
}
else
{
Debug.Fail("");
}
}
}
@@ -0,0 +1,135 @@
// Copyright (c) Josef Pihrt and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using Roslynator.CSharp;
using Roslynator.CSharp.CodeStyle;

namespace Roslynator.Formatting.CSharp;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class BlankLineAfterFileScopedNamespaceDeclarationAnalyzer : BaseDiagnosticAnalyzer
{
private static ImmutableArray<DiagnosticDescriptor> _supportedDiagnostics;

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
{
get
{
if (_supportedDiagnostics.IsDefault)
Immutable.InterlockedInitialize(ref _supportedDiagnostics, DiagnosticRules.BlankLineAfterFileScopedNamespaceDeclaration);

return _supportedDiagnostics;
}
}

public override void Initialize(AnalysisContext context)
{
base.Initialize(context);

context.RegisterSyntaxNodeAction(f => AnalyzeFileScopedNamespaceDeclaration(f), SyntaxKind.FileScopedNamespaceDeclaration);
}

private static void AnalyzeFileScopedNamespaceDeclaration(SyntaxNodeAnalysisContext context)
{
var namespaceDeclaration = (FileScopedNamespaceDeclarationSyntax)context.Node;

MemberDeclarationSyntax memberDeclaration = namespaceDeclaration.Members.FirstOrDefault();

if (memberDeclaration is null)
return;

BlankLineStyle style = context.GetBlankLineAfterFileScopedNamespaceDeclaration();

if (style == BlankLineStyle.None)
return;

BlankLineStyle currentStyle = GetCurrentStyle(namespaceDeclaration, memberDeclaration);

if (style != currentStyle)
return;

context.ReportDiagnostic(
DiagnosticRules.BlankLineAfterFileScopedNamespaceDeclaration,
Location.Create(namespaceDeclaration.SyntaxTree, new TextSpan(memberDeclaration.FullSpan.Start, 0)),
(style == BlankLineStyle.Add) ? "Add" : "Remove");
}

internal static BlankLineStyle GetCurrentStyle(
FileScopedNamespaceDeclarationSyntax namespaceDeclaration,
MemberDeclarationSyntax memberDeclaration)
{
(bool add, bool remove) = AnalyzeTrailingTrivia();

if (add || remove)
{
BlankLineStyle style = AnalyzeLeadingTrivia();

if (style == BlankLineStyle.Add)
{
if (add)
return style;
}
else if (style == BlankLineStyle.Remove)
{
if (remove)
return style;
}
}

return BlankLineStyle.None;

(bool add, bool remove) AnalyzeTrailingTrivia()
{
SyntaxTriviaList.Enumerator en = namespaceDeclaration.SemicolonToken.TrailingTrivia.GetEnumerator();

if (!en.MoveNext())
return (true, false);

if (en.Current.IsWhitespaceTrivia()
&& !en.MoveNext())
{
return (true, false);
}

if (en.Current.IsKind(SyntaxKind.SingleLineCommentTrivia)
&& !en.MoveNext())
{
return (true, false);
}

return (en.Current.IsEndOfLineTrivia())
? (true, true)
: (false, false);
}

BlankLineStyle AnalyzeLeadingTrivia()
{
SyntaxTriviaList.Enumerator en = memberDeclaration.GetLeadingTrivia().GetEnumerator();

if (!en.MoveNext())
return BlankLineStyle.Add;

if (en.Current.IsWhitespaceTrivia()
&& !en.MoveNext())
{
return BlankLineStyle.Add;
}

switch (en.Current.Kind())
{
case SyntaxKind.SingleLineCommentTrivia:
case SyntaxKind.SingleLineDocumentationCommentTrivia:
return BlankLineStyle.Add;
case SyntaxKind.EndOfLineTrivia:
return BlankLineStyle.Remove;
}

return BlankLineStyle.None;
}
}
}
Expand Up @@ -59,5 +59,6 @@ public static partial class DiagnosticIdentifiers
public const string NormalizeWhitespaceAtBeginningOfFile = "RCS0057";
public const string NormalizeWhitespaceAtEndOfFile = "RCS0058";
public const string PlaceNewLineAfterOrBeforeNullConditionalOperator = "RCS0059";
public const string BlankLineAfterFileScopedNamespaceDeclaration = "RCS0060";
}
}
12 changes: 12 additions & 0 deletions src/Formatting.Analyzers/CSharp/DiagnosticRules.Generated.cs
Expand Up @@ -621,5 +621,17 @@ public static partial class DiagnosticRules
helpLinkUri: DiagnosticIdentifiers.PlaceNewLineAfterOrBeforeNullConditionalOperator,
customTags: Array.Empty<string>());

/// <summary>RCS0060</summary>
public static readonly DiagnosticDescriptor BlankLineAfterFileScopedNamespaceDeclaration = DiagnosticDescriptorFactory.Create(
id: DiagnosticIdentifiers.BlankLineAfterFileScopedNamespaceDeclaration,
title: "Add/remove line after file scoped namespace declaration.",
messageFormat: "{0} line after file scoped namespace declaration.",
category: DiagnosticCategories.Roslynator,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: false,
description: null,
helpLinkUri: DiagnosticIdentifiers.BlankLineAfterFileScopedNamespaceDeclaration,
customTags: Array.Empty<string>());

}
}
31 changes: 31 additions & 0 deletions src/Formatting.Analyzers/Formatting.Analyzers.xml
Expand Up @@ -1481,4 +1481,35 @@ Default maximal length is 140.</Summary>
<Option Key="null_conditional_operator_new_line" IsRequired="true" />
</ConfigOptions>
</Analyzer>
<Analyzer Identifier="BlankLineAfterFileScopedNamespaceDeclaration">
<Id>RCS0060</Id>
<Title>Add/remove line after file scoped namespace declaration.</Title>
<MessageFormat>{0} line after file scoped namespace declaration.</MessageFormat>
<Category>Formatting</Category>
<DefaultSeverity>Info</DefaultSeverity>
<IsEnabledByDefault>false</IsEnabledByDefault>
<MinLanguageVersion>10.0</MinLanguageVersion>
<Samples>
<Sample>
<Before><![CDATA[namespace A.B // [|Id|]
public class C
{
}]]></Before>
<After><![CDATA[namespace A.B
public class C
{
}]]></After>
</Sample>
</Samples>
<ConfigOptions>
<Option Key="blank_line_after_file_scoped_namespace_declaration" IsRequired="true" />
</ConfigOptions>
<Links>
<Link>
<Url>https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/file-scoped-namespaces</Url>
<Text>File Scoped Namespaces</Text>
</Link>
</Links>
</Analyzer>
</Analyzers>

0 comments on commit 8320d2a

Please sign in to comment.