File: ProjectCapabilityResolver.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.VisualStudio.LanguageServices.Razor\Microsoft.VisualStudio.LanguageServices.Razor.csproj (Microsoft.VisualStudio.LanguageServices.Razor)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.VisualStudio.Razor.Extensions;
using Microsoft.VisualStudio.Razor.LiveShare;
using Microsoft.VisualStudio.Razor.LiveShare.Guest;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.Threading;
 
namespace Microsoft.VisualStudio.Razor;
 
[Export(typeof(IProjectCapabilityResolver))]
internal sealed class ProjectCapabilityResolver : IProjectCapabilityResolver, IDisposable
{
    private readonly ILiveShareSessionAccessor _liveShareSessionAccessor;
    private readonly IEnumerable<IProjectCapabilityListener> _projectCapabilityListeners;
    private readonly AsyncLazy<IVsUIShellOpenDocument> _lazyVsUIShellOpenDocument;
    private readonly ILogger _logger;
    private readonly JoinableTaskFactory _jtf;
    private readonly CancellationTokenSource _disposeTokenSource;
 
    [ImportingConstructor]
    public ProjectCapabilityResolver(
        ILiveShareSessionAccessor liveShareSessionAccessor,
        IVsService<SVsUIShellOpenDocument, IVsUIShellOpenDocument> vsUIShellOpenDocumentService,
        [ImportMany] IEnumerable<IProjectCapabilityListener> projectCapabilityListeners,
        ILoggerFactory loggerFactory,
        JoinableTaskContext joinableTaskContext)
    {
        _liveShareSessionAccessor = liveShareSessionAccessor;
        _projectCapabilityListeners = projectCapabilityListeners;
        _jtf = joinableTaskContext.Factory;
        _logger = loggerFactory.GetOrCreateLogger<ProjectCapabilityResolver>();
        _disposeTokenSource = new();
 
        // IVsService<,> doesn't provide a synchronous GetValue(...) method, so we wrap it in an AsyncLazy<>.
        _lazyVsUIShellOpenDocument = new(
            () => vsUIShellOpenDocumentService.GetValueAsync(_disposeTokenSource.Token),
            _jtf);
    }
 
    public void Dispose()
    {
        if (_disposeTokenSource.IsCancellationRequested)
        {
            return;
        }
 
        _disposeTokenSource.Cancel();
        _disposeTokenSource.Dispose();
    }
 
    public CapabilityCheckResult CheckCapability(string capability, string documentFilePath)
    {
        // If a LiveShare is currently active, we call into the host to resolve project capabilities.
        // Otherwise, we use the project that contains documentFilePath to resolve capabilities.
 
        return _liveShareSessionAccessor.IsGuestSessionActive
            ? LiveShareHostHasCapability(capability, documentFilePath)
            : ContainingProjectHasCapability(capability, documentFilePath);
    }
 
    private CapabilityCheckResult LiveShareHostHasCapability(string capability, string documentFilePath)
    {
        Debug.Assert(_liveShareSessionAccessor.IsGuestSessionActive);
 
        // Using JTF.Run(...) here isn't great, but this is how Razor's LiveShare implementation has
        // always worked. It won't be called unless a LiveShare collaboration session is active.
        return _jtf.Run(() => LiveShareHostHasCapabilityAsync(capability, documentFilePath, _disposeTokenSource.Token));
 
        async Task<CapabilityCheckResult> LiveShareHostHasCapabilityAsync(string capability, string documentFilePath, CancellationToken cancellationToken)
        {
            // On a guest box. The project hierarchy is not fully populated. We need to ask the host machine
            // questions about hierarchy capabilities.
 
            var session = _liveShareSessionAccessor.Session.AssumeNotNull();
 
            var remoteHierarchyService = await session
                .GetRemoteServiceAsync<IRemoteHierarchyService>(nameof(IRemoteHierarchyService), cancellationToken)
                .ConfigureAwait(false);
            if (remoteHierarchyService is null)
            {
                _logger.LogWarning("Live Share remote hierarchy service was unavailable during capability resolution.");
                return new(IsInProject: false, HasCapability: false);
            }
 
            var documentFilePathUri = session.ConvertLocalPathToSharedUri(documentFilePath);
            if (documentFilePathUri is null)
            {
                _logger.LogWarning($"Live Share could not convert document path to shared URI: {documentFilePath}");
                return new(IsInProject: false, HasCapability: false);
            }
 
            var isMatch = await remoteHierarchyService
                .HasCapabilityAsync(documentFilePathUri, capability, cancellationToken)
                .ConfigureAwait(false);
 
            return new(IsInProject: true, HasCapability: isMatch);
        }
    }
 
    private CapabilityCheckResult ContainingProjectHasCapability(string capability, string documentFilePath)
    {
        // This method is only ever called by our IFilePathToContentTypeProvider.TryGetContentTypeForFilePath(...) implementations.
        // We call AsyncLazy<T>.GetValue() below to get the value. If the work hasn't yet completed, we guard against a hidden
        // JTF.Run(...) on a background thread by asserting the UI thread.
 
        _jtf.AssertUIThread();
 
        var vsUIShellOpenDocument = _lazyVsUIShellOpenDocument.GetValue(_disposeTokenSource.Token);
 
        var result = vsUIShellOpenDocument.IsDocumentInAProject(documentFilePath, out var vsHierarchy, out _, out _, out var docInProj);
 
        if (!ErrorHandler.Succeeded(result))
        {
            _logger.LogWarning($"Project does not support LSP Editor because {nameof(IVsUIShellOpenDocument.IsDocumentInAProject)} failed with error code: {result:x8}");
            return new(IsInProject: false, HasCapability: false);
        }
 
        // vsHierarchy can be null here if the document is not included in a project.
        // In this scenario, the IVsUIShellOpenDocument.IsDocumentInAProject(..., ..., ..., ..., out int pDocInProj) call succeeds,
        // but pDocInProj == __VSDOCINPROJECT.DOCINPROJ_DocNotInProject.
        if (vsHierarchy is null)
        {
            _logger.LogWarning($"LSP Editor is not supported for file because it is not in a project: {documentFilePath}");
            return new(IsInProject: false, HasCapability: false);
        }
 
        if (((__VSDOCINPROJECT)docInProj) != __VSDOCINPROJECT.DOCINPROJ_DocInProject)
        {
            _logger.LogWarning($"LSP Editor is not supported for file because it is not in a project: {documentFilePath}");
            return new(IsInProject: false, HasCapability: false);
        }
 
        var isMatch = false;
        try
        {
            isMatch = vsHierarchy.IsCapabilityMatch(capability);
 
            if (vsHierarchy.GetProjectFilePath(_jtf) is { } projectFilePath)
            {
                foreach (var listener in _projectCapabilityListeners)
                {
                    // Notify all listeners of the capability match.
                    listener.OnProjectCapabilityMatched(projectFilePath, capability, isMatch);
                }
            }
        }
        catch (NotSupportedException)
        {
            // IsCapabilityMatch throws a NotSupportedException if it can't create a
            // BooleanSymbolExpressionEvaluator COM object
        }
        catch (ObjectDisposedException)
        {
            // IsCapabilityMatch throws an ObjectDisposedException if the underlying hierarchy has been disposed
        }
 
        return new(IsInProject: true, HasCapability: isMatch);
    }
}