Skip to content

Commit a801e65

Browse files
Merge pull request #58003 from CyrusNajmabadi/convertNamespaceSemicolon
Add feature to convert to file-scoped namespace just by typing `;` after a normal namespace.
2 parents b3d9ef6 + bff627e commit a801e65

File tree

5 files changed

+562
-9
lines changed

5 files changed

+562
-9
lines changed

src/Analyzers/CSharp/Analyzers/ConvertNamespace/ConvertNamespaceAnalysis.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,22 @@ public static bool CanOfferUseBlockScoped(OptionSet optionSet, BaseNamespaceDecl
4848
}
4949

5050
internal static bool CanOfferUseFileScoped(OptionSet optionSet, CompilationUnitSyntax root, BaseNamespaceDeclarationSyntax declaration, bool forAnalyzer)
51+
=> CanOfferUseFileScoped(optionSet, root, declaration, forAnalyzer, ((CSharpParseOptions)root.SyntaxTree.Options).LanguageVersion);
52+
53+
internal static bool CanOfferUseFileScoped(
54+
OptionSet optionSet,
55+
CompilationUnitSyntax root,
56+
BaseNamespaceDeclarationSyntax declaration,
57+
bool forAnalyzer,
58+
LanguageVersion version)
5159
{
5260
if (declaration is not NamespaceDeclarationSyntax namespaceDeclaration)
5361
return false;
5462

5563
if (namespaceDeclaration.OpenBraceToken.IsMissing)
5664
return false;
5765

58-
if (((CSharpParseOptions)root.SyntaxTree.Options).LanguageVersion < LanguageVersion.CSharp10)
66+
if (version < LanguageVersion.CSharp10)
5967
return false;
6068

6169
var option = optionSet.GetOption(CSharpCodeStyleOptions.NamespaceDeclarations);

src/Analyzers/CSharp/CodeFixes/ConvertNamespace/ConvertNamespaceTransform.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,25 @@ private static FileScopedNamespaceDeclarationSyntax ConvertNamespaceDeclaration(
6868
{
6969
// We move leading and trailing trivia on the open brace to just be trailing trivia on the semicolon, so we preserve
7070
// comments etc. logically at the top of the file.
71-
var semiColon = SyntaxFactory.Token(SyntaxKind.SemicolonToken)
72-
.WithTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia)
73-
.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.TrailingTrivia);
71+
var semiColon = SyntaxFactory.Token(SyntaxKind.SemicolonToken);
72+
73+
if (namespaceDeclaration.Name.GetTrailingTrivia().Any(t => t.IsSingleOrMultiLineComment()))
74+
{
75+
semiColon = semiColon.WithTrailingTrivia(namespaceDeclaration.Name.GetTrailingTrivia())
76+
.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia);
77+
}
78+
else
79+
{
80+
semiColon = semiColon.WithTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia);
81+
}
82+
83+
semiColon = semiColon.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.TrailingTrivia);
7484

7585
var fileScopedNamespace = SyntaxFactory.FileScopedNamespaceDeclaration(
7686
namespaceDeclaration.AttributeLists,
7787
namespaceDeclaration.Modifiers,
7888
namespaceDeclaration.NamespaceKeyword,
79-
namespaceDeclaration.Name,
89+
namespaceDeclaration.Name.WithoutTrailingTrivia(),
8090
semiColon,
8191
namespaceDeclaration.Externs,
8292
namespaceDeclaration.Usings,

src/EditorFeatures/CSharp/CompleteStatement/CompleteStatementCommandHandler.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public CompleteStatementCommandHandler(
6363

6464
public void ExecuteCommand(TypeCharCommandArgs args, Action nextCommandHandler, CommandExecutionContext executionContext)
6565
{
66-
var willMoveSemicolon = BeforeExecuteCommand(speculative: true, args: args, executionContext: executionContext);
66+
var willMoveSemicolon = BeforeExecuteCommand(speculative: true, args, executionContext);
6767
if (!willMoveSemicolon)
6868
{
6969
// Pass this on without altering the undo stack
@@ -74,7 +74,7 @@ public void ExecuteCommand(TypeCharCommandArgs args, Action nextCommandHandler,
7474
using var transaction = CaretPreservingEditTransaction.TryCreate(CSharpEditorResources.Complete_statement_on_semicolon, args.TextView, _textUndoHistoryRegistry, _editorOperationsFactoryService);
7575

7676
// Determine where semicolon should be placed and move caret to location
77-
BeforeExecuteCommand(speculative: false, args: args, executionContext: executionContext);
77+
BeforeExecuteCommand(speculative: false, args, executionContext);
7878

7979
// Insert the semicolon using next command handler
8080
nextCommandHandler();
@@ -107,10 +107,10 @@ private bool BeforeExecuteCommand(bool speculative, TypeCharCommandArgs args, Co
107107
return false;
108108
}
109109

110+
var cancellationToken = executionContext.OperationContext.UserCancellationToken;
110111
var syntaxFacts = document.GetLanguageService<ISyntaxFactsService>();
111-
var root = document.GetSyntaxRootSynchronously(executionContext.OperationContext.UserCancellationToken);
112+
var root = document.GetSyntaxRootSynchronously(cancellationToken);
112113

113-
var cancellationToken = executionContext.OperationContext.UserCancellationToken;
114114
if (!TryGetStartingNode(root, caret, out var currentNode, cancellationToken))
115115
{
116116
return false;
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Collections.Immutable;
7+
using System.ComponentModel.Composition;
8+
using System.Linq;
9+
using Microsoft.CodeAnalysis.CodeStyle;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
using Microsoft.CodeAnalysis.CSharp.CodeStyle;
12+
using Microsoft.CodeAnalysis.CSharp.ConvertNamespace;
13+
using Microsoft.CodeAnalysis.CSharp.Syntax;
14+
using Microsoft.CodeAnalysis.Editor.Implementation.AutomaticCompletion;
15+
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
16+
using Microsoft.CodeAnalysis.Editor.Shared.Options;
17+
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
18+
using Microsoft.CodeAnalysis.Formatting;
19+
using Microsoft.CodeAnalysis.Host.Mef;
20+
using Microsoft.CodeAnalysis.Options;
21+
using Microsoft.CodeAnalysis.Shared.Extensions;
22+
using Microsoft.CodeAnalysis.Text;
23+
using Microsoft.VisualStudio.Commanding;
24+
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
25+
using Microsoft.VisualStudio.Text;
26+
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
27+
using Microsoft.VisualStudio.Text.Operations;
28+
using Microsoft.VisualStudio.Utilities;
29+
30+
namespace Microsoft.CodeAnalysis.Editor.CSharp.CompleteStatement
31+
{
32+
/// <summary>
33+
/// Converts a block-scoped namespace to a file-scoped one if the user types <c>;</c> after its name.
34+
/// </summary>
35+
[Export(typeof(ICommandHandler))]
36+
[Export]
37+
[ContentType(ContentTypeNames.CSharpContentType)]
38+
[Name(nameof(ConvertNamespaceCommandHandler))]
39+
[Order(After = PredefinedCompletionNames.CompletionCommandHandler)]
40+
internal sealed class ConvertNamespaceCommandHandler : IChainedCommandHandler<TypeCharCommandArgs>
41+
{
42+
/// <summary>
43+
/// Annotation used so we can find the semicolon after formatting so that we can properly place the caret.
44+
/// </summary>
45+
private static readonly SyntaxAnnotation s_annotation = new();
46+
47+
/// <summary>
48+
/// A fake option set where the 'use file scoped' namespace option is on. That way we can call into the helpers
49+
/// and have the results come back positive for converting to file-scoped regardless of the current option
50+
/// value.
51+
/// </summary>
52+
private static readonly OptionSet s_optionSet = new OptionValueSet(
53+
ImmutableDictionary<OptionKey, object?>.Empty.Add(
54+
new OptionKey(CSharpCodeStyleOptions.NamespaceDeclarations.ToPublicOption()),
55+
new CodeStyleOption2<NamespaceDeclarationPreference>(
56+
NamespaceDeclarationPreference.FileScoped,
57+
NotificationOption2.Suggestion)));
58+
59+
private readonly ITextUndoHistoryRegistry _textUndoHistoryRegistry;
60+
private readonly IEditorOperationsFactoryService _editorOperationsFactoryService;
61+
private readonly IGlobalOptionService _globalOptions;
62+
63+
[ImportingConstructor]
64+
[Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
65+
public ConvertNamespaceCommandHandler(
66+
ITextUndoHistoryRegistry textUndoHistoryRegistry,
67+
IEditorOperationsFactoryService editorOperationsFactoryService, IGlobalOptionService globalOptions)
68+
{
69+
_textUndoHistoryRegistry = textUndoHistoryRegistry;
70+
_editorOperationsFactoryService = editorOperationsFactoryService;
71+
_globalOptions = globalOptions;
72+
}
73+
74+
public CommandState GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextCommandHandler)
75+
=> nextCommandHandler();
76+
77+
public string DisplayName => CSharpAnalyzersResources.Convert_to_file_scoped_namespace;
78+
79+
public void ExecuteCommand(TypeCharCommandArgs args, Action nextCommandHandler, CommandExecutionContext executionContext)
80+
{
81+
// Attempt to convert the block-namespace to a file-scoped namespace if we're at the right location.
82+
var convertedRoot = ConvertNamespace(args, executionContext);
83+
84+
// No matter if we succeeded or not, insert the semicolon. This way, when we convert, the user can still
85+
// hit ctrl-z to get back to the code with just the semicolon inserted.
86+
nextCommandHandler();
87+
88+
// If we weren't on a block namespace (or couldn't convert it for some reason), then bail out after
89+
// inserting the semicolon.
90+
if (convertedRoot == null)
91+
return;
92+
93+
// Otherwise, make a transaction for the edit and replace the buffer with the final text.
94+
using var transaction = CaretPreservingEditTransaction.TryCreate(
95+
this.DisplayName, args.TextView, _textUndoHistoryRegistry, _editorOperationsFactoryService);
96+
97+
var edit = args.SubjectBuffer.CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null);
98+
edit.Replace(new Span(0, args.SubjectBuffer.CurrentSnapshot.Length), convertedRoot.ToFullString());
99+
100+
edit.Apply();
101+
102+
// Attempt to place the caret right after the semicolon of the file-scoped namespace.
103+
var annotatedToken = convertedRoot.GetAnnotatedTokens(s_annotation).FirstOrDefault();
104+
if (annotatedToken != default)
105+
args.TextView.Caret.MoveTo(new SnapshotPoint(args.SubjectBuffer.CurrentSnapshot, annotatedToken.Span.End));
106+
107+
transaction?.Complete();
108+
}
109+
110+
/// <summary>
111+
/// Returns the updated file contents if semicolon is typed after a block-scoped namespace name that can be
112+
/// converted.
113+
/// </summary>
114+
private CompilationUnitSyntax? ConvertNamespace(
115+
TypeCharCommandArgs args,
116+
CommandExecutionContext executionContext)
117+
{
118+
if (args.TypedChar != ';' || !args.TextView.Selection.IsEmpty)
119+
return null;
120+
121+
if (!_globalOptions.GetOption(FeatureOnOffOptions.AutomaticallyCompleteStatementOnSemicolon))
122+
return null;
123+
124+
var subjectBuffer = args.SubjectBuffer;
125+
var caretOpt = args.TextView.GetCaretPoint(subjectBuffer);
126+
if (!caretOpt.HasValue)
127+
return null;
128+
129+
var caret = caretOpt.Value.Position;
130+
var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
131+
if (document == null)
132+
return null;
133+
134+
var cancellationToken = executionContext.OperationContext.UserCancellationToken;
135+
var root = (CompilationUnitSyntax)document.GetRequiredSyntaxRootSynchronously(cancellationToken);
136+
137+
// User has to be *after* an identifier token.
138+
var token = root.FindToken(caret);
139+
if (token.Kind() != SyntaxKind.IdentifierToken)
140+
return null;
141+
142+
if (caret < token.Span.End ||
143+
caret >= token.FullSpan.End)
144+
{
145+
return null;
146+
}
147+
148+
var namespaceDecl = token.GetRequiredParent().GetAncestor<NamespaceDeclarationSyntax>();
149+
if (namespaceDecl == null)
150+
return null;
151+
152+
// That identifier token has to be the last part of a namespace name.
153+
if (namespaceDecl.Name.GetLastToken() != token)
154+
return null;
155+
156+
// Pass in our special options, and C#10 so that if we can convert this to file-scoped, we will.
157+
if (!ConvertNamespaceAnalysis.CanOfferUseFileScoped(s_optionSet, root, namespaceDecl, forAnalyzer: true, LanguageVersion.CSharp10))
158+
return null;
159+
160+
var fileScopedNamespace = (FileScopedNamespaceDeclarationSyntax)ConvertNamespaceTransform.Convert(namespaceDecl);
161+
162+
// Place an annotation on the semicolon so that we can find it post-formatting to place the caret.
163+
fileScopedNamespace = fileScopedNamespace.WithSemicolonToken(
164+
fileScopedNamespace.SemicolonToken.WithAdditionalAnnotations(s_annotation));
165+
166+
var convertedRoot = root.ReplaceNode(namespaceDecl, fileScopedNamespace);
167+
var formattedRoot = (CompilationUnitSyntax)Formatter.Format(
168+
convertedRoot, Formatter.Annotation,
169+
document.Project.Solution.Workspace,
170+
options: null, rules: null, cancellationToken);
171+
172+
return formattedRoot;
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)