Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add feature to convert to file-scoped namespace just by typing ; after a normal namespace. #58003

Merged
merged 13 commits into from
Dec 1, 2021
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)
CyrusNajmabadi marked this conversation as resolved.
Show resolved Hide resolved
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)
CyrusNajmabadi marked this conversation as resolved.
Show resolved Hide resolved
return null;

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

var namespaceDecl = token.GetRequiredParent().GetAncestor<NamespaceDeclarationSyntax>();
CyrusNajmabadi marked this conversation as resolved.
Show resolved Hide resolved
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))
CyrusNajmabadi marked this conversation as resolved.
Show resolved Hide resolved
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
Member

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
Contributor

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