Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,22 @@ public static bool CanOfferUseBlockScoped(OptionSet optionSet, BaseNamespaceDecl
}

internal static bool CanOfferUseFileScoped(OptionSet optionSet, CompilationUnitSyntax root, BaseNamespaceDeclarationSyntax declaration, bool forAnalyzer)
=> CanOfferUseFileScoped(optionSet, root, declaration, forAnalyzer, ((CSharpParseOptions)root.SyntaxTree.Options).LanguageVersion);

internal static bool CanOfferUseFileScoped(
OptionSet optionSet,
CompilationUnitSyntax root,
BaseNamespaceDeclarationSyntax declaration,
bool forAnalyzer,
LanguageVersion version)
{
if (declaration is not NamespaceDeclarationSyntax namespaceDeclaration)
return false;

if (namespaceDeclaration.OpenBraceToken.IsMissing)
return false;

if (((CSharpParseOptions)root.SyntaxTree.Options).LanguageVersion < LanguageVersion.CSharp10)
if (version < LanguageVersion.CSharp10)
return false;

var option = optionSet.GetOption(CSharpCodeStyleOptions.NamespaceDeclarations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,25 @@ private static FileScopedNamespaceDeclarationSyntax ConvertNamespaceDeclaration(
{
// We move leading and trailing trivia on the open brace to just be trailing trivia on the semicolon, so we preserve
// comments etc. logically at the top of the file.
var semiColon = SyntaxFactory.Token(SyntaxKind.SemicolonToken)
.WithTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia)
.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.TrailingTrivia);
var semiColon = SyntaxFactory.Token(SyntaxKind.SemicolonToken);

if (namespaceDeclaration.Name.GetTrailingTrivia().Any(t => t.IsSingleOrMultiLineComment()))
{
semiColon = semiColon.WithTrailingTrivia(namespaceDeclaration.Name.GetTrailingTrivia())
.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia);
}
else
{
semiColon = semiColon.WithTrailingTrivia(namespaceDeclaration.OpenBraceToken.LeadingTrivia);
}

semiColon = semiColon.WithAppendedTrailingTrivia(namespaceDeclaration.OpenBraceToken.TrailingTrivia);

var fileScopedNamespace = SyntaxFactory.FileScopedNamespaceDeclaration(
namespaceDeclaration.AttributeLists,
namespaceDeclaration.Modifiers,
namespaceDeclaration.NamespaceKeyword,
namespaceDeclaration.Name,
namespaceDeclaration.Name.WithoutTrailingTrivia(),
semiColon,
namespaceDeclaration.Externs,
namespaceDeclaration.Usings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public CompleteStatementCommandHandler(

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

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

// Insert the semicolon using next command handler
nextCommandHandler();
Expand Down Expand Up @@ -107,10 +107,10 @@ private bool BeforeExecuteCommand(bool speculative, TypeCharCommandArgs args, Co
return false;
}

var cancellationToken = executionContext.OperationContext.UserCancellationToken;
var syntaxFacts = document.GetLanguageService<ISyntaxFactsService>();
var root = document.GetSyntaxRootSynchronously(executionContext.OperationContext.UserCancellationToken);
var root = document.GetSyntaxRootSynchronously(cancellationToken);

var cancellationToken = executionContext.OperationContext.UserCancellationToken;
if (!TryGetStartingNode(root, caret, out var currentNode, cancellationToken))
{
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Immutable;
using System.ComponentModel.Composition;
using System.Linq;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.ConvertNamespace;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editor.Implementation.AutomaticCompletion;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Options;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;
using Microsoft.VisualStudio.Utilities;

namespace Microsoft.CodeAnalysis.Editor.CSharp.CompleteStatement
{
/// <summary>
/// Converts a block-scoped namespace to a file-scoped one if the user types <c>;</c> after its name.
/// </summary>
[Export(typeof(ICommandHandler))]
[Export]
[ContentType(ContentTypeNames.CSharpContentType)]
[Name(nameof(ConvertNamespaceCommandHandler))]
[Order(After = PredefinedCompletionNames.CompletionCommandHandler)]
internal sealed class ConvertNamespaceCommandHandler : IChainedCommandHandler<TypeCharCommandArgs>
{
/// <summary>
/// Annotation used so we can find the semicolon after formatting so that we can properly place the caret.
/// </summary>
private static readonly SyntaxAnnotation s_annotation = new();

/// <summary>
/// A fake option set where the 'use file scoped' namespace option is on. That way we can call into the helpers
/// and have the results come back positive for converting to file-scoped regardless of the current option
/// value.
/// </summary>
private static readonly OptionSet s_optionSet = new OptionValueSet(
ImmutableDictionary<OptionKey, object?>.Empty.Add(
new OptionKey(CSharpCodeStyleOptions.NamespaceDeclarations.ToPublicOption()),
new CodeStyleOption2<NamespaceDeclarationPreference>(
NamespaceDeclarationPreference.FileScoped,
NotificationOption2.Suggestion)));

private readonly ITextUndoHistoryRegistry _textUndoHistoryRegistry;
private readonly IEditorOperationsFactoryService _editorOperationsFactoryService;
private readonly IGlobalOptionService _globalOptions;

[ImportingConstructor]
[Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
public ConvertNamespaceCommandHandler(
ITextUndoHistoryRegistry textUndoHistoryRegistry,
IEditorOperationsFactoryService editorOperationsFactoryService, IGlobalOptionService globalOptions)
{
_textUndoHistoryRegistry = textUndoHistoryRegistry;
_editorOperationsFactoryService = editorOperationsFactoryService;
_globalOptions = globalOptions;
}

public CommandState GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextCommandHandler)
=> nextCommandHandler();

public string DisplayName => CSharpAnalyzersResources.Convert_to_file_scoped_namespace;

public void ExecuteCommand(TypeCharCommandArgs args, Action nextCommandHandler, CommandExecutionContext executionContext)
{
// Attempt to convert the block-namespace to a file-scoped namespace if we're at the right location.
var convertedRoot = ConvertNamespace(args, executionContext);

// No matter if we succeeded or not, insert the semicolon. This way, when we convert, the user can still
// hit ctrl-z to get back to the code with just the semicolon inserted.
nextCommandHandler();

// If we weren't on a block namespace (or couldn't convert it for some reason), then bail out after
// inserting the semicolon.
if (convertedRoot == null)
return;

// Otherwise, make a transaction for the edit and replace the buffer with the final text.
using var transaction = CaretPreservingEditTransaction.TryCreate(
this.DisplayName, args.TextView, _textUndoHistoryRegistry, _editorOperationsFactoryService);

var edit = args.SubjectBuffer.CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null);
edit.Replace(new Span(0, args.SubjectBuffer.CurrentSnapshot.Length), convertedRoot.ToFullString());

edit.Apply();

// Attempt to place the caret right after the semicolon of the file-scoped namespace.
var annotatedToken = convertedRoot.GetAnnotatedTokens(s_annotation).FirstOrDefault();
if (annotatedToken != default)
args.TextView.Caret.MoveTo(new SnapshotPoint(args.SubjectBuffer.CurrentSnapshot, annotatedToken.Span.End));

transaction?.Complete();
}

/// <summary>
/// Returns the updated file contents if semicolon is typed after a block-scoped namespace name that can be
/// converted.
/// </summary>
private CompilationUnitSyntax? ConvertNamespace(
TypeCharCommandArgs args,
CommandExecutionContext executionContext)
{
if (args.TypedChar != ';' || !args.TextView.Selection.IsEmpty)
return null;

if (!_globalOptions.GetOption(FeatureOnOffOptions.AutomaticallyCompleteStatementOnSemicolon))
return null;

var subjectBuffer = args.SubjectBuffer;
var caretOpt = args.TextView.GetCaretPoint(subjectBuffer);
if (!caretOpt.HasValue)
return null;

var caret = caretOpt.Value.Position;
var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
if (document == null)
return null;

var cancellationToken = executionContext.OperationContext.UserCancellationToken;
var root = (CompilationUnitSyntax)document.GetRequiredSyntaxRootSynchronously(cancellationToken);

// User has to be *after* an identifier token.
var token = root.FindToken(caret);
if (token.Kind() != SyntaxKind.IdentifierToken)
return null;

if (caret < token.Span.End ||
caret >= token.FullSpan.End)
{
return null;
}

var namespaceDecl = token.GetRequiredParent().GetAncestor<NamespaceDeclarationSyntax>();
if (namespaceDecl == null)
return null;

// That identifier token has to be the last part of a namespace name.
if (namespaceDecl.Name.GetLastToken() != token)
return null;

// Pass in our special options, and C#10 so that if we can convert this to file-scoped, we will.
if (!ConvertNamespaceAnalysis.CanOfferUseFileScoped(s_optionSet, root, namespaceDecl, forAnalyzer: true, LanguageVersion.CSharp10))
return null;

var fileScopedNamespace = (FileScopedNamespaceDeclarationSyntax)ConvertNamespaceTransform.Convert(namespaceDecl);

// Place an annotation on the semicolon so that we can find it post-formatting to place the caret.
fileScopedNamespace = fileScopedNamespace.WithSemicolonToken(
fileScopedNamespace.SemicolonToken.WithAdditionalAnnotations(s_annotation));

var convertedRoot = root.ReplaceNode(namespaceDecl, fileScopedNamespace);
var formattedRoot = (CompilationUnitSyntax)Formatter.Format(
convertedRoot, Formatter.Annotation,
document.Project.Solution.Workspace,
options: null, rules: null, cancellationToken);
Comment on lines +167 to +170
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗ This assumes the file is formatted according to the formatter, which is not a safe assumption. This feature is only allowed to remove a uniform number of leading spaces from each line in the namespace block, and is not allowed to force potentially incorrect formatting throughout the file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw this tweet that indicated the codefix for converting to file scoped namespaces forced a format on users https://twitter.com/rickbrewPDN/status/1463361397677379590

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, this has come up on Discord a couple of times too, and ASP.NET ran into it too: dotnet/aspnetcore#38681 (comment)


return formattedRoot;
}
}
}
Loading