diff --git a/src/Analyzers/CSharp/Analyzers/ConvertNamespace/ConvertNamespaceAnalysis.cs b/src/Analyzers/CSharp/Analyzers/ConvertNamespace/ConvertNamespaceAnalysis.cs index 91b3c3ba03474..a605d1b98802a 100644 --- a/src/Analyzers/CSharp/Analyzers/ConvertNamespace/ConvertNamespaceAnalysis.cs +++ b/src/Analyzers/CSharp/Analyzers/ConvertNamespace/ConvertNamespaceAnalysis.cs @@ -48,6 +48,14 @@ 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; @@ -55,7 +63,7 @@ internal static bool CanOfferUseFileScoped(OptionSet optionSet, CompilationUnitS 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); diff --git a/src/Analyzers/CSharp/CodeFixes/ConvertNamespace/ConvertNamespaceTransform.cs b/src/Analyzers/CSharp/CodeFixes/ConvertNamespace/ConvertNamespaceTransform.cs index aaa9cdfd27c99..3050716634ac4 100644 --- a/src/Analyzers/CSharp/CodeFixes/ConvertNamespace/ConvertNamespaceTransform.cs +++ b/src/Analyzers/CSharp/CodeFixes/ConvertNamespace/ConvertNamespaceTransform.cs @@ -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, diff --git a/src/EditorFeatures/CSharp/CompleteStatement/CompleteStatementCommandHandler.cs b/src/EditorFeatures/CSharp/CompleteStatement/CompleteStatementCommandHandler.cs index 7408575ea9ca3..81f053ad3f23d 100644 --- a/src/EditorFeatures/CSharp/CompleteStatement/CompleteStatementCommandHandler.cs +++ b/src/EditorFeatures/CSharp/CompleteStatement/CompleteStatementCommandHandler.cs @@ -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 @@ -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(); @@ -107,10 +107,10 @@ private bool BeforeExecuteCommand(bool speculative, TypeCharCommandArgs args, Co return false; } + var cancellationToken = executionContext.OperationContext.UserCancellationToken; var syntaxFacts = document.GetLanguageService(); - 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; diff --git a/src/EditorFeatures/CSharp/ConvertNamespace/ConvertNamespaceCommandHandler.cs b/src/EditorFeatures/CSharp/ConvertNamespace/ConvertNamespaceCommandHandler.cs new file mode 100644 index 0000000000000..f18338c3709f3 --- /dev/null +++ b/src/EditorFeatures/CSharp/ConvertNamespace/ConvertNamespaceCommandHandler.cs @@ -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 +{ + /// + /// Converts a block-scoped namespace to a file-scoped one if the user types ; after its name. + /// + [Export(typeof(ICommandHandler))] + [Export] + [ContentType(ContentTypeNames.CSharpContentType)] + [Name(nameof(ConvertNamespaceCommandHandler))] + [Order(After = PredefinedCompletionNames.CompletionCommandHandler)] + internal sealed class ConvertNamespaceCommandHandler : IChainedCommandHandler + { + /// + /// Annotation used so we can find the semicolon after formatting so that we can properly place the caret. + /// + private static readonly SyntaxAnnotation s_annotation = new(); + + /// + /// 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. + /// + private static readonly OptionSet s_optionSet = new OptionValueSet( + ImmutableDictionary.Empty.Add( + new OptionKey(CSharpCodeStyleOptions.NamespaceDeclarations.ToPublicOption()), + new CodeStyleOption2( + 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 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(); + } + + /// + /// Returns the updated file contents if semicolon is typed after a block-scoped namespace name that can be + /// converted. + /// + 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(); + 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); + + return formattedRoot; + } + } +} diff --git a/src/EditorFeatures/CSharpTest/ConvertNamespace/ConvertNamespaceCommandHandlerTests.cs b/src/EditorFeatures/CSharpTest/ConvertNamespace/ConvertNamespaceCommandHandlerTests.cs new file mode 100644 index 0000000000000..738bbb1a11c82 --- /dev/null +++ b/src/EditorFeatures/CSharpTest/ConvertNamespace/ConvertNamespaceCommandHandlerTests.cs @@ -0,0 +1,360 @@ +// 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.Linq; +using System.Xml.Linq; +using Microsoft.CodeAnalysis.Editor.CSharp.CompleteStatement; +using Microsoft.CodeAnalysis.Editor.Shared.Options; +using Microsoft.CodeAnalysis.Editor.UnitTests; +using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.VisualStudio.Commanding; +using Roslyn.Test.Utilities; +using Xunit; + +namespace Microsoft.CodeAnalysis.Editor.CSharp.UnitTests.ConvertNamespace +{ + [UseExportProvider] + public class ConvertNamespaceCommandHandlerTests + { + internal sealed class ConvertNamespaceTestState : AbstractCommandHandlerTestState + { + private static readonly TestComposition s_composition = EditorTestCompositions.EditorFeaturesWpf.AddParts( + typeof(ConvertNamespaceCommandHandler)); + + private readonly ConvertNamespaceCommandHandler _commandHandler; + + public ConvertNamespaceTestState(XElement workspaceElement) + : base(workspaceElement, s_composition) + { + _commandHandler = (ConvertNamespaceCommandHandler)GetExportedValues(). + Single(c => c is ConvertNamespaceCommandHandler); + } + + public static ConvertNamespaceTestState CreateTestState(string markup) + => new(GetWorkspaceXml(markup)); + + public static XElement GetWorkspaceXml(string markup) + => XElement.Parse(string.Format(@" + + + {0} + +", markup)); + + internal void AssertCodeIs(string expectedCode) + { + Assert.Equal(expectedCode, TextView.TextSnapshot.GetText()); + } + + public void SendTypeChar(char ch) + => SendTypeChar(ch, _commandHandler.ExecuteCommand, () => EditorOperations.InsertText(ch.ToString())); + } + + [WpfFact] + public void TestSingleName() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace N$$ +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace N; + +class C +{ +} +"); + } + + [WpfFact] + public void TestOptionOff() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace N$$ +{ + class C + { + } +}"); + + testState.Workspace.SetOptions(testState.Workspace.Options.WithChangedOption( + FeatureOnOffOptions.AutomaticallyCompleteStatementOnSemicolon, false)); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace N; +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestDottedName1() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace A.B$$ +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace A.B; + +class C +{ +} +"); + } + + [WpfFact] + public void TestDottedName2() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace A.$$B +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace A.;B +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestDottedName3() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace A$$.B +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace A;.B +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestDottedName4() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace $$A.B +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace ;A.B +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestAfterWhitespace() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace A.B $$ +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace A.B; + +class C +{ +} +"); + } + + [WpfFact] + public void TestBeforeName() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace $$N +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace ;N +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestNestedNamespace() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace N$$ +{ + namespace N2 + { + class C + { + } + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace N; +{ + namespace N2 + { + class C + { + } + } +}"); + } + + [WpfFact] + public void TestSiblingNamespace() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace N$$ +{ +} + +namespace N2 +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace N; +{ +} + +namespace N2 +{ + class C + { + } +}"); + } + + [WpfFact] + public void TestOuterUsings() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@" +using A; +using B; + +namespace N$$ +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@" +using A; +using B; + +namespace N; + +class C +{ +} +"); + } + + [WpfFact] + public void TestInnerUsings() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@" +namespace N$$ +{ + using A; + using B; + + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@" +namespace N; + +using A; +using B; + +class C +{ +} +"); + } + + [WpfFact] + public void TestCommentAfterName() + { + using var testState = ConvertNamespaceTestState.CreateTestState( +@"namespace N$$ // Goo +{ + class C + { + } +}"); + + testState.SendTypeChar(';'); + testState.AssertCodeIs( +@"namespace N; // Goo + +class C +{ +} +"); + } + } +}