Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.EditAndContinue;
using Microsoft.CodeAnalysis.Editor.Implementation.TextDiffing;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Preview;
Expand Down Expand Up @@ -90,13 +91,34 @@ internal abstract class AbstractPreviewFactoryService<TDifferenceViewer>(
var newProject = projectChanges.NewProject;

// Exclude changes to unchangeable documents if they will be ignored when applied to workspace.
foreach (var documentId in projectChanges.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true, ignoreUnchangeableDocuments))
var allChangedDocuments = projectChanges.GetChangedDocuments(onlyGetDocumentsWithTextChanges: false, ignoreUnchangeableDocuments: false).ToHashSet();
Copy link
Member

Choose a reason for hiding this comment

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

Why would we not use ignoreUnchangeableDocuments here? Can we update filename even if the document is unchangeable?

var textChangedDocuments = projectChanges.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true, ignoreUnchangeableDocuments).ToHashSet();

foreach (var documentId in textChangedDocuments)
{
cancellationToken.ThrowIfCancellationRequested();
previewItems.Add(new SolutionPreviewItem(documentId.ProjectId, documentId, async c =>
await CreateChangedDocumentPreviewViewAsync(oldSolution.GetRequiredDocument(documentId), newSolution.GetRequiredDocument(documentId), zoomLevel, c).ConfigureAwaitRunInline()));
}

foreach (var documentId in allChangedDocuments)
Copy link
Member Author

Choose a reason for hiding this comment

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

this was needed as otherwise we woudln't show the preview window if only renaming a file. which means there was no place to invoke 'fix all in ...'

Copy link
Member

Choose a reason for hiding this comment

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

Please document this scenario. Also, are there any other changes to a document that should be considered other than name?

{
cancellationToken.ThrowIfCancellationRequested();

if (textChangedDocuments.Contains(documentId))
continue;

var oldDocument = oldProject.GetRequiredDocument(documentId);
var newDocument = newProject.GetRequiredDocument(documentId);

if (oldDocument.Name != newDocument.Name)
{
previewItems.Add(new SolutionPreviewItem(documentId.ProjectId, documentId,
string.Format(WorkspacesResources.Rename_0_to_1, oldDocument.Name, newDocument.Name)));
break;
}
}

foreach (var documentId in projectChanges.GetAddedDocuments())
{
cancellationToken.ThrowIfCancellationRequested();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ internal sealed class RefactorOrFixAllSuggestedAction(
originalSolution,
subjectBuffer,
fixAllState.FixAllProvider,
new RefactorOrFixAllCodeAction(fixAllState, showPreviewChangesDialog: true)),
new RefactorOrFixAllCodeAction(fixAllState, fixAllState.FixAllProvider.ShowPreviewChangesDialog)),
ITelemetryDiagnosticID<string?>
{
public string? GetDiagnosticID() => diagnosticTelemetryId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ namespace Microsoft.CodeAnalysis.CSharp.CodeRefactorings.MoveType;
internal sealed class CSharpMoveTypeService() :
AbstractMoveTypeService<CSharpMoveTypeService, BaseTypeDeclarationSyntax, BaseNamespaceDeclarationSyntax, CompilationUnitSyntax>
{
protected override bool IsTrivialTypeContainer(BaseTypeDeclarationSyntax typeDeclaration)
=> typeDeclaration is TypeDeclarationSyntax { Members: [BaseTypeDeclarationSyntax] };

protected override (string name, int arity) GetSymbolNameAndArity(BaseTypeDeclarationSyntax syntax)
=> (syntax.Identifier.ValueText, syntax is TypeDeclarationSyntax { TypeParameterList.Parameters.Count: var arity } ? arity : 0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ private string CreateDisplayText()

public override string Title { get; }

public override string? EquivalenceKey => _operationKind.ToString();

protected override async Task<ImmutableArray<CodeActionOperation>> ComputeOperationsAsync(
IProgress<CodeAnalysisProgress> progress, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal abstract class AbstractMoveTypeService : IMoveTypeService

public abstract Task<Solution> GetModifiedSolutionAsync(Document document, TextSpan textSpan, MoveTypeOperationKind operationKind, CancellationToken cancellationToken);
public abstract Task<ImmutableArray<CodeAction>> GetRefactoringAsync(Document document, TextSpan textSpan, CancellationToken cancellationToken);
public abstract Task<ImmutableArray<string>> TryGetSuggestedFileRenamesAsync(Document document, CancellationToken cancellationToken);
}

internal abstract partial class AbstractMoveTypeService<TService, TTypeDeclarationSyntax, TNamespaceDeclarationSyntax, TCompilationUnitSyntax> :
Expand All @@ -42,6 +43,12 @@ internal abstract partial class AbstractMoveTypeService<TService, TTypeDeclarati

protected abstract bool IsMemberDeclaration(SyntaxNode syntaxNode);

/// <summary>
/// If this is a type declaration that only contains a type declaration within it. In this case, we want the file
/// name to match the inner type, not this outer type.
/// </summary>
protected abstract bool IsTrivialTypeContainer(TTypeDeclarationSyntax typeDeclaration);

protected string GetSymbolName(TTypeDeclarationSyntax syntax)
=> GetSymbolNameAndArity(syntax).name;

Expand Down Expand Up @@ -83,14 +90,21 @@ private ImmutableArray<CodeAction> CreateActions(
if (typeDeclaration is null)
return [];

return CreateOperations(document, typeDeclaration, checkTopLevelTypesOnly: true)
.SelectAsArray(tuple => (CodeAction)new MoveTypeCodeAction((TService)this, document, typeDeclaration, tuple.operationKind, tuple.fileName));
}

private ImmutableArray<(string fileName, MoveTypeOperationKind operationKind)> CreateOperations(
SemanticDocument document, TTypeDeclarationSyntax typeDeclaration, bool checkTopLevelTypesOnly)
{
var documentNameWithoutExtension = GetDocumentNameWithoutExtension(document);
var typeMatchesDocumentName = TypeMatchesDocumentName(typeDeclaration, documentNameWithoutExtension);

// if type name matches document name, per style conventions, we have nothing to do.
if (typeMatchesDocumentName)
return [];

using var _ = ArrayBuilder<CodeAction>.GetInstance(out var actions);
using var _ = ArrayBuilder<(string fileName, MoveTypeOperationKind operationKind)>.GetInstance(out var actions);

var manyTypes = MultipleTopLevelTypeDeclarationInSourceDocument(document.Root);
var isNestedType = IsNestedType(typeDeclaration);
Expand All @@ -111,20 +125,20 @@ private ImmutableArray<CodeAction> CreateActions(
if (manyTypes || isNestedType || isClassNextToGlobalStatements)
{
foreach (var fileName in suggestedFileNames)
actions.Add(GetCodeAction(fileName, operationKind: MoveTypeOperationKind.MoveType));
actions.Add((fileName, operationKind: MoveTypeOperationKind.MoveType));
}

// (2) Add rename file and rename type code actions:
// Case: No type declaration in file matches the file name.
if (!AnyTopLevelTypeMatchesDocumentName())
if (!AnyTypeMatchesDocumentName())
{
foreach (var fileName in suggestedFileNames)
actions.Add(GetCodeAction(fileName, operationKind: MoveTypeOperationKind.RenameFile));
actions.Add((fileName, operationKind: MoveTypeOperationKind.RenameFile));

// Only if the document name can be legal identifier in the language, offer to rename type with document name
if (syntaxFacts.IsValidIdentifier(documentNameWithoutExtension))
{
actions.Add(GetCodeAction(
actions.Add((
fileName: documentNameWithoutExtension,
operationKind: MoveTypeOperationKind.RenameType));
}
Expand All @@ -134,13 +148,25 @@ private ImmutableArray<CodeAction> CreateActions(

return actions.ToImmutableAndClear();

bool AnyTopLevelTypeMatchesDocumentName()
=> TopLevelTypeDeclarations(document.Root).Any(
bool AnyTypeMatchesDocumentName()
=> TypeDeclarations(document.Root, checkTopLevelTypesOnly).Any(
typeDeclaration => TypeMatchesDocumentName(
typeDeclaration, documentNameWithoutExtension));
}

public override async Task<ImmutableArray<string>> TryGetSuggestedFileRenamesAsync(Document document, CancellationToken cancellationToken)
{
var semanticDocument = await SemanticDocument.CreateAsync(document, cancellationToken).ConfigureAwait(false);

// Try to find the first interesting type in the file, as that's what we'll be trying to rename to.
// A type is intersting if it's not a trivial container of a nested type.
var allTypeDeclarations = TypeDeclarations(semanticDocument.Root, checkTopLevelTypesOnly: false);
var firstInterestingType = allTypeDeclarations.FirstOrDefault(t => !IsTrivialTypeContainer(t));
if (firstInterestingType == null)
return [];

MoveTypeCodeAction GetCodeAction(string fileName, MoveTypeOperationKind operationKind)
=> new((TService)this, document, typeDeclaration, operationKind, fileName);
var operations = CreateOperations(semanticDocument, firstInterestingType, checkTopLevelTypesOnly: false);
return operations.SelectAsArray(t => t.operationKind == MoveTypeOperationKind.RenameFile, t => t.fileName);
}

private static bool ClassNextToGlobalStatements(SyntaxNode root, ISyntaxFactsService syntaxFacts)
Expand All @@ -156,10 +182,24 @@ private static bool IsNestedType(TTypeDeclarationSyntax typeNode)
/// optimized for perf, uses Skip(1).Any() instead of Count() > 1
/// </remarks>
private static bool MultipleTopLevelTypeDeclarationInSourceDocument(SyntaxNode root)
=> TopLevelTypeDeclarations(root).Skip(1).Any();
=> TypeDeclarations(root, checkTopLevelTypesOnly: true).Skip(1).Any();

private static IEnumerable<TTypeDeclarationSyntax> TopLevelTypeDeclarations(SyntaxNode root)
=> root.DescendantNodes(n => n is TCompilationUnitSyntax or TNamespaceDeclarationSyntax).OfType<TTypeDeclarationSyntax>();
private static IEnumerable<TTypeDeclarationSyntax> TypeDeclarations(SyntaxNode root, bool checkTopLevelTypesOnly)
{
var descendantNodes = root.DescendantNodes(n =>
{
// Always walk into the compilation unit and namespace declarations so we at least find the top level types.
if (n is TCompilationUnitSyntax or TNamespaceDeclarationSyntax)
return true;

// If we are only looking for top level types, do not walk into type declarations.
if (!checkTopLevelTypesOnly && n is TTypeDeclarationSyntax)
return true;

return false;
});
return descendantNodes.OfType<TTypeDeclarationSyntax>();
}

private static string GetDocumentNameWithoutExtension(SemanticDocument document)
=> Path.GetFileNameWithoutExtension(document.Document.Name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ internal interface IMoveTypeService : ILanguageService
Task<ImmutableArray<CodeAction>> GetRefactoringAsync(Document document, TextSpan textSpan, CancellationToken cancellationToken);

Task<Solution> GetModifiedSolutionAsync(Document document, TextSpan textSpan, MoveTypeOperationKind operationKind, CancellationToken cancellationToken);

Task<ImmutableArray<string>> TryGetSuggestedFileRenamesAsync(Document document, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@
// 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.Collections.Generic;
using System.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Threading;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.CodeRefactorings.MoveType;

[ExportCodeRefactoringProvider(LanguageNames.CSharp, LanguageNames.VisualBasic,
Name = PredefinedCodeRefactoringProviderNames.MoveTypeToFile), Shared]
[method: ImportingConstructor]
[method: SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")]
internal sealed class MoveTypeCodeRefactoringProvider() : CodeRefactoringProvider
internal sealed partial class MoveTypeCodeRefactoringProvider() : CodeRefactoringProvider
{
public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
{
Expand All @@ -26,6 +31,9 @@ public override async Task ComputeRefactoringsAsync(CodeRefactoringContext conte

var service = document.GetRequiredLanguageService<IMoveTypeService>();
var actions = await service.GetRefactoringAsync(document, textSpan, cancellationToken).ConfigureAwait(false);
context.RegisterRefactorings(actions);
context.RegisterRefactorings(actions, textSpan);
}

public override RefactorAllProvider? GetRefactorAllProvider()
=> new MoveTypeFixAllProvider();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Threading;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.CodeRefactorings.MoveType;

internal sealed partial class MoveTypeCodeRefactoringProvider
{
private sealed class MoveTypeFixAllProvider : RefactorAllProvider
{
public override IEnumerable<RefactorAllScope> GetSupportedRefactorAllScopes()
=> [RefactorAllScope.Project];

/// <summary>
/// Preview changes dialog can't represent file renames currently. So this ends up just showing the user that
/// nothing changed (which is obviously misleading). So we just suppress the dialog entirely and just make the
/// change.
/// </summary>
internal override bool ShowPreviewChangesDialog
=> false;

public override Task<CodeAction?> GetRefactoringAsync(RefactorAllContext refactorAllContext)
{
if (refactorAllContext.CodeActionEquivalenceKey != MoveTypeOperationKind.RenameFile.ToString())
return SpecializedTasks.Null<CodeAction>();

var codeAction = CodeAction.Create(
FeaturesResources.Rename_files_to_match_types_within,
async cancellationToken =>
{
var documentsToCheck = await refactorAllContext.GetRefactorAllSpansAsync(cancellationToken).ConfigureAwait(false);

var documentIdsAndNames = await ProducerConsumer<(DocumentId documentId, string newFileName)>.RunParallelAsync(
documentsToCheck.Keys,
produceItems: static async (document, callback, args, cancellationToken) =>
{
var service = document.GetRequiredLanguageService<IMoveTypeService>();
var suggestedNames = await service.TryGetSuggestedFileRenamesAsync(document, cancellationToken).ConfigureAwait(false);
if (suggestedNames.IsEmpty)
return;

foreach (var name in suggestedNames)
{
// Ensure the new name isn't one that will conflict with an existing document.
if (CollidesWithExistingDocument(document.Project.State, document.State, name))
continue;

callback((document.Id, name));
}
},
args: default(VoidResult),
cancellationToken).ConfigureAwait(false);

var currentSolution = refactorAllContext.Solution;
foreach (var (documentId, newFileName) in documentIdsAndNames)
{
var projectState = currentSolution.GetRequiredProjectState(documentId.ProjectId);
var documentState = projectState.DocumentStates.GetRequiredState(documentId);
if (CollidesWithExistingDocument(projectState, documentState, newFileName))
continue;

currentSolution = currentSolution.WithDocumentName(documentId, newFileName);
}

return currentSolution;
});

return Task.FromResult<CodeAction?>(codeAction);
}

private static bool CollidesWithExistingDocument(ProjectState projectState, TextDocumentState document, string newName)
{
return projectState.DocumentStates.States.Any(
static (kvp, args) => kvp.Value.Name == args.newName && kvp.Value.Folders.SequenceEqual(args.document.Folders),
arg: (document, newName));
}
}
}
3 changes: 3 additions & 0 deletions src/Features/Core/Portable/FeaturesResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -3226,4 +3226,7 @@ Zero-width positive lookbehind assertions are typically used at the beginning of
<data name="extension_block" xml:space="preserve">
<value>extension block</value>
</data>
<data name="Rename_files_to_match_types_within" xml:space="preserve">
<value>Rename files to match types within</value>
</data>
</root>
5 changes: 5 additions & 0 deletions src/Features/Core/Portable/xlf/FeaturesResources.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Features/Core/Portable/xlf/FeaturesResources.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading