File: CodeRefactorings\SyncNamespace\AbstractSyncNamespaceCodeRefactoringProvider.State.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ChangeNamespace;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CodeRefactorings.SyncNamespace;
 
internal abstract partial class AbstractSyncNamespaceCodeRefactoringProvider<TNamespaceDeclarationSyntax, TCompilationUnitSyntax, TMemberDeclarationSyntax>
    : CodeRefactoringProvider
    where TNamespaceDeclarationSyntax : SyntaxNode
    where TCompilationUnitSyntax : SyntaxNode
    where TMemberDeclarationSyntax : SyntaxNode
{
    internal sealed class State
    {
        /// <summary>
        /// The document in which the refactoring is triggered.
        /// </summary>
        public Document Document { get; }
 
        /// <summary>
        /// The applicable container node based on cursor location,
        /// which will be used to change namespace.
        /// </summary>
        public SyntaxNode Container { get; }
 
        /// <summary>
        /// This is the new name we want to change the namespace to.
        /// Empty string means global namespace, whereas null means change namespace action is not available.
        /// </summary>
        public string? TargetNamespace { get; }
 
        /// <summary>
        /// This is the part of the declared namespace that is contained in default namespace.
        /// We will use this to construct target folder to move the file to.
        /// For example, if default namespace is `A` and declared namespace is `A.B.C`, 
        /// this would be `B.C`.
        /// </summary>
        public string? RelativeDeclaredNamespace { get; }
 
        private State(
            Document document,
            SyntaxNode container,
            string? targetNamespace,
            string? relativeDeclaredNamespace)
        {
            Document = document;
            Container = container;
            TargetNamespace = targetNamespace;
            RelativeDeclaredNamespace = relativeDeclaredNamespace;
        }
 
        public static async Task<State?> CreateAsync(
            AbstractSyncNamespaceCodeRefactoringProvider<TNamespaceDeclarationSyntax, TCompilationUnitSyntax, TMemberDeclarationSyntax> provider,
            Document document,
            TextSpan textSpan,
            CancellationToken cancellationToken)
        {
            // User must put cursor on one of the nodes described below to trigger the refactoring. 
            // For each scenario, all requirements must be met. Some of them are checked by `TryGetApplicableInvocationNodeAsync`, 
            // rest by `IChangeNamespaceService.CanChangeNamespaceAsync`.
            // 
            // - A namespace declaration node that is the only namespace declaration in the document and all types are declared in it:
            //    1. No nested namespace declarations (even it's empty).
            //    2. The cursor is on the name of the namespace declaration.
            //    3. The name of the namespace is valid (i.e. no errors).
            //    4. No partial type declared in the namespace. Otherwise its multiple declaration will
            //       end up in different namespace.
            //
            // - A compilation unit node that contains no namespace declaration:
            //    1. The cursor is on the name of first declared type.
            //    2. No partial type declared in the document. Otherwise its multiple declaration will
            //       end up in different namespace.
 
            var applicableNode = await provider.TryGetApplicableInvocationNodeAsync(document, textSpan, cancellationToken).ConfigureAwait(false);
            if (applicableNode == null)
            {
                return null;
            }
 
            var changeNamespaceService = document.GetRequiredLanguageService<IChangeNamespaceService>();
            var canChange = await changeNamespaceService.CanChangeNamespaceAsync(document, applicableNode, cancellationToken).ConfigureAwait(false);
 
            if (!canChange || !IsDocumentPathRootedInProjectFolder(document))
            {
                return null;
            }
 
            var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
 
            // We can't determine what the expected namespace would be without knowing the default namespace.
            var defaultNamespace = GetDefaultNamespace(document, syntaxFacts);
            if (defaultNamespace == null)
            {
                return null;
            }
 
            string declaredNamespace;
            if (applicableNode is TCompilationUnitSyntax)
            {
                declaredNamespace = string.Empty;
            }
            else if (applicableNode is TNamespaceDeclarationSyntax)
            {
                var syntaxGenerator = SyntaxGenerator.GetGenerator(document);
                declaredNamespace = syntaxGenerator.GetName(applicableNode);
            }
            else
            {
                throw ExceptionUtilities.Unreachable();
            }
 
            // Namespace can't be changed if we can't construct a valid qualified identifier from folder names.
            // In this case, we might still be able to provide refactoring to move file to new location.
            var targetNamespace = PathMetadataUtilities.TryBuildNamespaceFromFolders(document.Folders, syntaxFacts, defaultNamespace);
 
            // No action required if namespace already matches folders.
            if (syntaxFacts.StringComparer.Equals(targetNamespace, declaredNamespace))
            {
                return null;
            }
 
            // Only provide "move file" action if default namespace contains declared namespace.
            // For example, if the default namespace is `Microsoft.CodeAnalysis`, and declared
            // namespace is `System.Diagnostics`, it's very likely this document is an outlier  
            // in the project and user probably has some special rule for it.
            var relativeNamespace = GetRelativeNamespace(defaultNamespace, declaredNamespace, syntaxFacts);
 
            return new State(document, applicableNode, targetNamespace, relativeNamespace);
        }
 
        /// <summary>
        /// Determines if the actual file path matches its logical path in project 
        /// which is constructed as [project_root_path]\Logical\Folders\. The refactoring 
        /// is triggered only when the two match. The reason of doing this is we don't really know
        /// the user's intention of keeping the file path out-of-sync with its logical path.
        /// </summary>
        private static bool IsDocumentPathRootedInProjectFolder(Document document)
        {
            var absoluteDirectoryPath = PathUtilities.GetDirectoryName(document.FilePath);
            if (absoluteDirectoryPath is null)
                return false;
 
            var projectRoot = PathUtilities.GetDirectoryName(document.Project.FilePath);
            if (projectRoot is null)
                return false;
 
            var folderPath = Path.Combine([.. document.Folders]);
            var logicalDirectoryPath = PathUtilities.CombineAbsoluteAndRelativePaths(projectRoot, folderPath);
            if (logicalDirectoryPath is null)
                return false;
 
            return PathUtilities.PathsEqual(absoluteDirectoryPath, logicalDirectoryPath);
        }
 
        private static string? GetDefaultNamespace(Document document, ISyntaxFactsService syntaxFacts)
        {
            var solution = document.Project.Solution;
            var linkedIds = document.GetLinkedDocumentIds();
            var documents = linkedIds.SelectAsArray(solution.GetRequiredDocument).Add(document);
 
            // For all projects containing all the linked documents, bail if 
            // 1. Any of them doesn't have default namespace, or
            // 2. Multiple default namespace are found. (this might be possible by tweaking project file).
            // The refactoring depends on a single default namespace to operate.
            var defaultNamespaceFromProjects = new HashSet<string?>(
                    documents.Select(d => d.Project.DefaultNamespace),
                    syntaxFacts.StringComparer);
 
            if (defaultNamespaceFromProjects.Count > 1)
                return null;
 
            return defaultNamespaceFromProjects.SingleOrDefault();
        }
 
        /// <summary>
        /// Try get the relative namespace for <paramref name="namespace"/> based on <paramref name="relativeTo"/>,
        /// if <paramref name="relativeTo"/> is the containing namespace of <paramref name="namespace"/>. Otherwise,
        /// Returns null.
        /// For example:
        /// - If <paramref name="relativeTo"/> is "A.B" and <paramref name="namespace"/> is "A.B.C.D", then
        /// the relative namespace is "C.D".
        /// - If <paramref name="relativeTo"/> is "A.B" and <paramref name="namespace"/> is also "A.B", then
        /// the relative namespace is "".
        /// - If <paramref name="relativeTo"/> is "" then the relative namespace us <paramref name="namespace"/>.
        /// </summary>
        private static string? GetRelativeNamespace(string relativeTo, string @namespace, ISyntaxFactsService syntaxFacts)
        {
            Debug.Assert(relativeTo != null && @namespace != null);
 
            if (syntaxFacts.StringComparer.Equals(@namespace, relativeTo))
            {
                return string.Empty;
            }
            else if (relativeTo.Length == 0)
            {
                return @namespace;
            }
            else if (relativeTo.Length >= @namespace.Length)
            {
                return null;
            }
 
            var containingText = relativeTo + ".";
            var namespacePrefix = @namespace[..containingText.Length];
 
            return syntaxFacts.StringComparer.Equals(containingText, namespacePrefix)
                ? @namespace[(relativeTo.Length + 1)..]
                : null;
        }
    }
}