File: Rename\Renamer.RenameDocumentActionSet.cs
Web Access
Project: ..\..\..\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
 
namespace Microsoft.CodeAnalysis.Rename
{
    public static partial class Renamer
    {
        /// <summary>
        /// Information about rename document calls that allows them to be applied as individual actions. Actions are individual units of work
        /// that can change the contents of one or more document in the solution. Even if the <see cref="ApplicableActions"/> is empty, the 
        /// document metadata will still be updated by calling <see cref="UpdateSolutionAsync(Solution, ImmutableArray{RenameDocumentAction}, CancellationToken)"/>
        /// <para />
        /// To apply all actions use <see cref="UpdateSolutionAsync(Solution, CancellationToken)"/>, or use a subset
        /// of the actions by calling <see cref="UpdateSolutionAsync(Solution, ImmutableArray{RenameDocumentAction}, CancellationToken)"/>. 
        /// Actions can be applied in any order.
        /// Each action has a description of the changes that it will apply that can be presented to a user.
        /// </summary>
        public sealed class RenameDocumentActionSet
        {
            private readonly DocumentId _documentId;
            private readonly string _documentName;
            private readonly ImmutableArray<string> _documentFolders;
            private readonly DocumentRenameOptions _options;
 
            internal RenameDocumentActionSet(
                ImmutableArray<RenameDocumentAction> actions,
                DocumentId documentId,
                string documentName,
                ImmutableArray<string> documentFolders,
                DocumentRenameOptions options)
            {
                ApplicableActions = actions;
                _documentFolders = documentFolders;
                _documentId = documentId;
                _documentName = documentName;
                _options = options;
            }
 
            /// <summary>
            /// All applicable actions computed for the action. Action set may be empty, which represents updates to document 
            /// contents rather than metadata. Document metadata will still not be updated unless <see cref="UpdateSolutionAsync(Solution, ImmutableArray{RenameDocumentAction}, CancellationToken)" /> 
            /// is called.
            /// </summary>
            public ImmutableArray<RenameDocumentAction> ApplicableActions { get; }
 
            /// <summary>
            /// Same as calling <see cref="UpdateSolutionAsync(Solution, ImmutableArray{RenameDocumentAction}, CancellationToken)"/> with 
            /// <see cref="ApplicableActions"/> as the argument
            /// </summary>
            public Task<Solution> UpdateSolutionAsync(Solution solution, CancellationToken cancellationToken)
                => UpdateSolutionAsync(solution, ApplicableActions, cancellationToken);
 
            /// <summary>
            /// Applies each <see cref="RenameDocumentAction"/> in order and returns the final solution. 
            /// All actions must be contained in <see cref="ApplicableActions" />
            /// </summary>
            /// <remarks>
            /// An empty action set is still allowed and will return a modified solution
            /// that will update the document properties as appropriate. This means we 
            /// can still support when <see cref="ApplicableActions"/> is empty. It's desirable
            /// that consumers can call a rename API to produce a <see cref="RenameDocumentActionSet"/> and
            /// immediately call <see cref="UpdateSolutionAsync(Solution, ImmutableArray{RenameDocumentAction}, CancellationToken)"/> without
            /// having to inspect the returned <see cref="ApplicableActions"/>.
            /// </remarks>
            public async Task<Solution> UpdateSolutionAsync(Solution solution, ImmutableArray<RenameDocumentAction> actions, CancellationToken cancellationToken)
            {
                if (solution is null)
                {
                    throw new ArgumentNullException(nameof(solution));
                }
 
                if (actions.Any(static (a, self) => !self.ApplicableActions.Contains(a), this))
                {
                    throw new ArgumentException(string.Format(WorkspacesResources.Cannot_apply_action_that_is_not_in_0, nameof(ApplicableActions)));
                }
 
                // Prior to updating the solution it's possible the document id has changed between the time 
                // the document action info was generated and when the solution update is applied. We
                // do a best effort to still locate the document if the id has changed.
                var document = GetDocument(solution);
 
                // If the document was found in the solution then the current id will be durable across actions
                // since we own the solution snapshot at this point. 
                var documentId = document.Id;
 
                // Make sure that the document name and folders are updated to what we expect them to be
                solution = solution
                    .WithDocumentName(documentId, _documentName)
                    .WithDocumentFolders(documentId, _documentFolders);
 
                // Apply each action individually. Order should not matter
                foreach (var action in actions)
                {
                    document = solution.GetRequiredDocument(documentId);
                    solution = await action.GetModifiedSolutionAsync(document, _options, cancellationToken).ConfigureAwait(false);
                }
 
                return solution;
            }
 
            /// <summary>
            /// Attempts to find the document in the solution. Tries by documentId first, but 
            /// that's not always reliable between analysis and application of the rename actions
            /// </summary>
            private Document GetDocument(Solution solution)
            {
                // DocumentId is the best bet for finding a document,
                // but it's possible the document was renamed or moved
                // before actions were applied.
                if (solution.ContainsDocument(_documentId))
                {
                    return solution.GetRequiredDocument(_documentId);
                }
 
                // There are cases where we expect work to be done between when the ActionSet is first generated
                // and when the solution can be worked on. This work can remove and add documents as part of the rename
                // and thus won't have the same DocumentId. 
                // 
                // 1. Right click solution explorer > rename
                // 2. Call Renamer.RenameDocument to generate dialog for user (near synchronous) 
                // 3. CPS changes file on disk
                // 4. CPS updates project file if necessary
                // 5. In dotnet project system, a new design time build is started
                // 6. Re-evaluates what files need to be passed to Roslyn. Tell Roslyn of file changed. This is a remove then add. (Asynchronous on project-system side)
                // 7. We update the workspace snapshot in the VS Workspace. Synchronous and controlled by project system.
                // 8. RenameDocumentActionSet should be applied to the current Workspace Solution
                // 
                // Since step 6 and 7 remove and add the document, step 8 can't depend on the DocumentId being available and the same document.
                // We are guaranateed that the project is the same and we know what the document name will be. 
                // https://github.com/dotnet/roslyn/issues/43729 tracks designing a more elagent system that can help alleviate
                // this issue. 
                var project = solution.GetRequiredProject(_documentId.ProjectId);
                return project.Documents.FirstOrDefault(d => d.Name == _documentName && d.Folders.SequenceEqual(_documentFolders))
                    ?? throw new InvalidOperationException(WorkspaceExtensionsResources.The_solution_does_not_contain_the_specified_document);
            }
        }
    }
}