File: HostWorkspace\LoadedProject.cs
Web Access
Project: src\src\LanguageServer\Microsoft.CodeAnalysis.LanguageServer\Microsoft.CodeAnalysis.LanguageServer.csproj (Microsoft.CodeAnalysis.LanguageServer)
// 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.Immutable;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageServer.Handler.DebugConfiguration;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.CodeAnalysis.ProjectSystem;
using Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Logging;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
 
/// <summary>
/// Represents a single project loaded for a single target.
/// </summary>
internal sealed class LoadedProject : IDisposable
{
    private readonly string _projectFilePath;
    private readonly string _projectDirectory;
 
    private readonly ProjectSystemProject _projectSystemProject;
    private readonly ProjectSystemProjectOptionsProcessor _optionsProcessor;
    private readonly IFileChangeContext _fileChangeContext;
    private readonly ProjectTargetFrameworkManager _targetFrameworkManager;
 
    /// <summary>
    /// The most recent version of the project design time build information; held onto so the next reload we can diff against this.
    /// </summary>
    private ProjectFileInfo? _mostRecentFileInfo;
    /// <summary>
    /// The most recent version of the file glob matcher.  Held onto 
    /// </summary>
    private Lazy<ImmutableArray<Matcher>>? _mostRecentFileMatchers;
    private IWatchedFile? _mostRecentProjectAssetsFileWatcher;
    private ImmutableArray<CommandLineReference> _mostRecentMetadataReferences = ImmutableArray<CommandLineReference>.Empty;
    private ImmutableArray<CommandLineAnalyzerReference> _mostRecentAnalyzerReferences = ImmutableArray<CommandLineAnalyzerReference>.Empty;
 
    public LoadedProject(ProjectSystemProject projectSystemProject, SolutionServices solutionServices, IFileChangeWatcher fileWatcher, ProjectTargetFrameworkManager targetFrameworkManager)
    {
        Contract.ThrowIfNull(projectSystemProject.FilePath);
        _projectFilePath = projectSystemProject.FilePath;
 
        _projectSystemProject = projectSystemProject;
        _optionsProcessor = new ProjectSystemProjectOptionsProcessor(projectSystemProject, solutionServices);
        _targetFrameworkManager = targetFrameworkManager;
 
        // We'll watch the directory for all source file changes
        // TODO: we only should listen for add/removals here, but we can't specify such a filter now
        _projectDirectory = Path.GetDirectoryName(_projectFilePath)!;
 
        _fileChangeContext = fileWatcher.CreateContext([
            new(_projectDirectory, ".cs"),
            new(_projectDirectory, ".cshtml"),
            new(_projectDirectory, ".razor")
        ]);
        _fileChangeContext.FileChanged += FileChangedContext_FileChanged;
 
        // Start watching for file changes for the project file as well
        _fileChangeContext.EnqueueWatchingFile(_projectFilePath);
    }
 
    private void FileChangedContext_FileChanged(object? sender, string filePath)
    {
        // If the project file itself changed, we almost certainly need to reload the project.
        if (string.Equals(filePath, _projectFilePath, StringComparison.OrdinalIgnoreCase))
        {
            NeedsReload?.Invoke(this, EventArgs.Empty);
            return;
        }
 
        var matchers = _mostRecentFileMatchers?.Value;
        if (matchers is null)
        {
            return;
        }
 
        // Check if the file path matches any of the globs in the project file.
        foreach (var matcher in matchers)
        {
            // CPS re-creates the msbuild globs from the includes/excludes/removes and the project XML directory and
            // ignores the MSBuildGlob.FixedDirectoryPart.  We'll do the same here and match using the project directory as the relative path.
            // See https://devdiv.visualstudio.com/DevDiv/_git/CPS?path=/src/Microsoft.VisualStudio.ProjectSystem/Build/MsBuildGlobFactory.cs
            var relativeDirectory = _projectDirectory;
 
            var matches = matcher.Match(relativeDirectory, filePath);
            if (matches.HasMatches)
            {
                NeedsReload?.Invoke(this, EventArgs.Empty);
                return;
            }
        }
    }
 
    public event EventHandler? NeedsReload;
 
    public string? GetTargetFramework()
    {
        Contract.ThrowIfNull(_mostRecentFileInfo, "We haven't been given a loaded project yet, so we can't provide the existing TFM.");
        return _mostRecentFileInfo.TargetFramework;
    }
 
    public void Dispose()
    {
        _optionsProcessor.Dispose();
        _projectSystemProject.RemoveFromWorkspace();
    }
 
    public async ValueTask<(ProjectLoadTelemetryReporter.TelemetryInfo, bool NeedsRestore)> UpdateWithNewProjectInfoAsync(ProjectFileInfo newProjectInfo, ILogger logger)
    {
        if (_mostRecentFileInfo != null)
        {
            // We should never be changing the fundamental identity of this project; if this happens we really should have done a full unload/reload.
            Contract.ThrowIfFalse(newProjectInfo.FilePath == _mostRecentFileInfo.FilePath);
            Contract.ThrowIfFalse(newProjectInfo.TargetFramework == _mostRecentFileInfo.TargetFramework);
        }
 
        var disposableBatchScope = await _projectSystemProject.CreateBatchScopeAsync(CancellationToken.None).ConfigureAwait(false);
        await using var _ = disposableBatchScope.ConfigureAwait(false);
 
        var projectDisplayName = Path.GetFileNameWithoutExtension(newProjectInfo.FilePath)!;
        var projectFullPathWithTargetFramework = newProjectInfo.FilePath;
 
        if (newProjectInfo.TargetFramework != null)
        {
            var targetFrameworkSuffix = " (" + newProjectInfo.TargetFramework + ")";
            projectDisplayName += targetFrameworkSuffix;
            projectFullPathWithTargetFramework += targetFrameworkSuffix;
        }
 
        _projectSystemProject.DisplayName = projectDisplayName;
        _projectSystemProject.OutputFilePath = newProjectInfo.OutputFilePath;
        _projectSystemProject.OutputRefFilePath = newProjectInfo.OutputRefFilePath;
        _projectSystemProject.GeneratedFilesOutputDirectory = newProjectInfo.GeneratedFilesOutputDirectory;
        _projectSystemProject.CompilationOutputAssemblyFilePath = newProjectInfo.IntermediateOutputFilePath;
 
        if (newProjectInfo.TargetFrameworkIdentifier != null)
        {
            _targetFrameworkManager.UpdateIdentifierForProject(_projectSystemProject.Id, newProjectInfo.TargetFrameworkIdentifier);
        }
 
        _optionsProcessor.SetCommandLine(newProjectInfo.CommandLineArgs);
        var commandLineArguments = _optionsProcessor.GetParsedCommandLineArguments();
 
        UpdateProjectSystemProjectCollection(
            newProjectInfo.Documents,
            _mostRecentFileInfo?.Documents,
            DocumentFileInfoComparer.Instance,
            document => _projectSystemProject.AddSourceFile(document.FilePath, folders: document.Folders),
            document => _projectSystemProject.RemoveSourceFile(document.FilePath),
            "Project {0} now has {1} source file(s).");
 
        var relativePathResolver = new RelativePathResolver(commandLineArguments.ReferencePaths, commandLineArguments.BaseDirectory);
        var metadataReferences = commandLineArguments.MetadataReferences.Select(cr =>
        {
            // The relative path resolver calls File.Exists() to see if the path doesn't exist; it guarantees that generally the path returned
            // is to an actual file on disk. And it needs to call File.Exists() in some cases if there are reference paths to have to search. But as a fallback
            // we'll accept the resolved path since in the common case it's a file that just might not exist on disk yet.
            var absolutePath =
                relativePathResolver.ResolvePath(cr.Reference, baseFilePath: null) ??
                FileUtilities.ResolveRelativePath(cr.Reference, commandLineArguments.BaseDirectory);
 
            return absolutePath is not null ? new CommandLineReference(absolutePath, cr.Properties) : default;
        }).Where(static cr => cr.Reference is not null).ToImmutableArray();
 
        UpdateProjectSystemProjectCollection(
            metadataReferences,
            _mostRecentMetadataReferences,
            EqualityComparer<CommandLineReference>.Default, // CommandLineReference already implements equality
            reference => _projectSystemProject.AddMetadataReference(reference.Reference, reference.Properties),
            reference => _projectSystemProject.RemoveMetadataReference(reference.Reference, reference.Properties),
            "Project {0} now has {1} reference(s).");
 
        // Now that we've updated it hold onto the old list of references so we can remove them if there's a later update
        _mostRecentMetadataReferences = metadataReferences;
 
        var analyzerReferences = commandLineArguments.AnalyzerReferences.Select(cr =>
        {
            // Note that unlike regular references, we do not resolve these with the relative path resolver that searches reference paths
            var absolutePath = FileUtilities.ResolveRelativePath(cr.FilePath, commandLineArguments.BaseDirectory);
            return absolutePath is not null ? new CommandLineAnalyzerReference(absolutePath) : default;
        }).Where(static cr => cr.FilePath is not null).ToImmutableArray();
 
        UpdateProjectSystemProjectCollection(
            analyzerReferences,
            _mostRecentAnalyzerReferences,
            EqualityComparer<CommandLineAnalyzerReference>.Default, // CommandLineAnalyzerReference already implements equality
            reference => _projectSystemProject.AddAnalyzerReference(reference.FilePath),
            reference => _projectSystemProject.RemoveAnalyzerReference(reference.FilePath),
            "Project {0} now has {1} analyzer reference(s).");
 
        _mostRecentAnalyzerReferences = analyzerReferences;
 
        UpdateProjectSystemProjectCollection(
            newProjectInfo.AdditionalDocuments,
            _mostRecentFileInfo?.AdditionalDocuments,
            DocumentFileInfoComparer.Instance,
            document => _projectSystemProject.AddAdditionalFile(document.FilePath, folders: document.Folders),
            document => _projectSystemProject.RemoveAdditionalFile(document.FilePath),
            "Project {0} now has {1} additional file(s).");
 
        UpdateProjectSystemProjectCollection(
            newProjectInfo.AnalyzerConfigDocuments,
            _mostRecentFileInfo?.AnalyzerConfigDocuments,
            DocumentFileInfoComparer.Instance,
            document => _projectSystemProject.AddAnalyzerConfigFile(document.FilePath),
            document => _projectSystemProject.RemoveAnalyzerConfigFile(document.FilePath),
            "Project {0} now has {1} analyzer config file(s).");
 
        UpdateProjectSystemProjectCollection(
            newProjectInfo.AdditionalDocuments.Where(TreatAsIsDynamicFile),
            _mostRecentFileInfo?.AdditionalDocuments.Where(TreatAsIsDynamicFile),
            DocumentFileInfoComparer.Instance,
            document => _projectSystemProject.AddDynamicSourceFile(document.FilePath, folders: ImmutableArray<string>.Empty),
            document => _projectSystemProject.RemoveDynamicSourceFile(document.FilePath),
            "Project {0} now has {1} dynamic file(s).");
 
        WatchProjectAssetsFile(newProjectInfo, _fileChangeContext);
 
        var needsRestore = ProjectDependencyHelper.NeedsRestore(newProjectInfo, _mostRecentFileInfo, logger);
 
        _mostRecentFileMatchers = new Lazy<ImmutableArray<Matcher>>(() =>
        {
            return newProjectInfo.FileGlobs.Select(glob =>
            {
                var matcher = new Matcher();
                matcher.AddIncludePatterns(glob.Includes);
                matcher.AddExcludePatterns(glob.Excludes);
                matcher.AddExcludePatterns(glob.Removes);
                return matcher;
            }).ToImmutableArray();
        });
        _mostRecentFileInfo = newProjectInfo;
 
        Contract.ThrowIfNull(_projectSystemProject.CompilationOptions, "Compilation options cannot be null for C#/VB project");
        var outputKind = _projectSystemProject.CompilationOptions.OutputKind;
        var telemetryInfo = new ProjectLoadTelemetryReporter.TelemetryInfo { OutputKind = outputKind, MetadataReferences = metadataReferences };
        return (telemetryInfo, needsRestore);
 
        // logMessage should be a string with two placeholders; the first is the project name, the second is the number of items.
        void UpdateProjectSystemProjectCollection<T>(IEnumerable<T> loadedCollection, IEnumerable<T>? oldLoadedCollection, IEqualityComparer<T> comparer, Action<T> addItem, Action<T> removeItem, string logMessage)
        {
            var newItems = new HashSet<T>(loadedCollection, comparer);
            var oldItems = new HashSet<T>(comparer);
            var oldItemsCount = oldItems.Count;
 
            if (oldLoadedCollection != null)
            {
                foreach (var item in oldLoadedCollection)
                    oldItems.Add(item);
            }
 
            foreach (var newItem in newItems)
            {
                // If oldItems already has this, we don't need to add it again. We'll remove it, and what is left in oldItems is stuff to remove
                if (!oldItems.Remove(newItem))
                    addItem(newItem);
            }
 
            foreach (var oldItem in oldItems)
            {
                removeItem(oldItem);
            }
 
            if (newItems.Count != oldItemsCount)
                logger.LogTrace(logMessage, projectFullPathWithTargetFramework, newItems.Count);
        }
 
        void WatchProjectAssetsFile(ProjectFileInfo currentProjectInfo, IFileChangeContext fileChangeContext)
        {
            if (_mostRecentFileInfo?.ProjectAssetsFilePath == currentProjectInfo.ProjectAssetsFilePath)
            {
                // The file path hasn't changed, just keep using the same watcher.
                return;
            }
 
            // Dispose of the last once since we're changing the file we're watching.
            _mostRecentProjectAssetsFileWatcher?.Dispose();
 
            IWatchedFile? currentWatcher = null;
            if (currentProjectInfo.ProjectAssetsFilePath != null)
            {
                currentWatcher = fileChangeContext.EnqueueWatchingFile(currentProjectInfo.ProjectAssetsFilePath);
            }
 
            _mostRecentProjectAssetsFileWatcher = currentWatcher;
        }
    }
 
    private static bool TreatAsIsDynamicFile(DocumentFileInfo info)
    {
        var extension = Path.GetExtension(info.FilePath);
        return extension is ".cshtml" or ".razor";
    }
 
    private sealed class DocumentFileInfoComparer : IEqualityComparer<DocumentFileInfo>
    {
        public static IEqualityComparer<DocumentFileInfo> Instance = new DocumentFileInfoComparer();
 
        private DocumentFileInfoComparer()
        {
        }
 
        public bool Equals(DocumentFileInfo? x, DocumentFileInfo? y)
        {
            return StringComparer.Ordinal.Equals(x?.FilePath, y?.FilePath);
        }
 
        public int GetHashCode(DocumentFileInfo obj)
        {
            return StringComparer.Ordinal.GetHashCode(obj.FilePath);
        }
    }
}