diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProject.cs new file mode 100644 index 0000000000000..2eface64befc9 --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProject.cs @@ -0,0 +1,297 @@ +// 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 System.Runtime.InteropServices; +using System.Security; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Threading; +using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; +using Microsoft.Extensions.Logging; +using Roslyn.Utilities; +using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager; + +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; + +/// +/// Manages a canonical miscellaneous files project that is shared across all genuine miscellaneous files. +/// This avoids running a design-time build for each individual misc file. +/// +internal sealed class CanonicalMiscFilesProject +{ + private readonly LanguageServerWorkspaceFactory _workspaceFactory; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(initialCount: 1); + private readonly string _canonicalProjectPath; + private readonly string _emptyFilePath; + + // State protected by _gate + private LoadedProject? _loadedProject; + private bool _hasBeenInitialized; + + public ProjectId? Id => _loadedProject?.ProjectSystemProject.Id; + + public CanonicalMiscFilesProject( + LanguageServerWorkspaceFactory workspaceFactory, + ILogger logger) + { + _workspaceFactory = workspaceFactory; + _logger = logger; + + // Create a temp location for the canonical project + var tempDirectory = GetCanonicalProjectDirectory(); + Directory.CreateDirectory(tempDirectory); + + _emptyFilePath = Path.Combine(tempDirectory, "EmptyFile.cs"); + _canonicalProjectPath = Path.Combine(tempDirectory, "CanonicalMiscFiles.csproj"); + + // Create an empty file for the initial build + File.WriteAllText(_emptyFilePath, string.Empty); + } + + private static string GetCanonicalProjectDirectory() + { + string baseDirectory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.GetTempPath() + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + return Path.Combine(baseDirectory, "roslyn-lsp", "canonical-misc-files"); + } + + /// + /// Ensures the canonical project is initialized with a design-time build. + /// + public async Task EnsureInitializedAsync( + BuildHostProcessManager buildHostProcessManager, + IFileChangeWatcher fileChangeWatcher, + ImmutableDictionary additionalProperties, + CancellationToken cancellationToken) + { + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + if (_hasBeenInitialized) + { + return _loadedProject; + } + + _hasBeenInitialized = true; + + try + { + // Create the project XML content + var projectContent = CreateCanonicalProjectContent(); + + // Write the project file to disk + File.WriteAllText(_canonicalProjectPath, projectContent); + + // Perform the design-time build + const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore; + var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, _canonicalProjectPath, dotnetPath: null, cancellationToken); + var loadedFile = await buildHost.LoadProjectAsync(_canonicalProjectPath, projectContent, languageName: LanguageNames.CSharp, cancellationToken); + + var diagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken); + if (diagnosticLogItems.Any(item => item.Kind is DiagnosticLogItemKind.Error)) + { + foreach (var diagnostic in diagnosticLogItems) + { + _logger.LogError($"Error loading canonical misc files project: {diagnostic.Message}"); + } + return null; + } + + var loadedProjectInfos = await loadedFile.GetProjectFileInfosAsync(cancellationToken); + if (loadedProjectInfos.Length == 0) + { + _logger.LogError("No project info loaded for canonical misc files project"); + return null; + } + + var projectInfo = loadedProjectInfos[0]; + var projectFactory = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory; + + // Create the project in the workspace + var projectSystemName = "Canonical Miscellaneous Files"; + var projectCreationInfo = new ProjectSystemProjectCreationInfo + { + AssemblyName = projectSystemName, + FilePath = _canonicalProjectPath, + CompilationOutputAssemblyFilePath = projectInfo.IntermediateOutputFilePath, + }; + + var projectSystemProject = await projectFactory.CreateAndAddToWorkspaceAsync( + projectSystemName, + LanguageNames.CSharp, + projectCreationInfo, + _workspaceFactory.ProjectSystemHostInfo); + + _loadedProject = new LoadedProject( + projectSystemProject, + projectFactory, + fileChangeWatcher, + _workspaceFactory.TargetFrameworkManager); + + // Update the project with the build information + await _loadedProject.UpdateWithNewProjectInfoAsync(projectInfo, isMiscellaneousFile: true, _logger); + + // Remove the empty file that was used for the initial build + var workspace = projectFactory.Workspace; + var project = workspace.CurrentSolution.GetProject(_loadedProject.ProjectSystemProject.Id); + if (project != null) + { + var emptyDocument = project.Documents.FirstOrDefault(d => d.FilePath == _emptyFilePath); + if (emptyDocument != null) + { + await projectFactory.ApplyChangeToWorkspaceAsync( + w => w.OnDocumentRemoved(emptyDocument.Id), + cancellationToken); + } + } + + _logger.LogInformation("Canonical miscellaneous files project initialized successfully"); + return _loadedProject; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize canonical miscellaneous files project"); + return null; + } + } + } + + /// + /// Adds a document to the canonical project. + /// + public async Task AddDocumentAsync( + string filePath, + SourceText sourceText, + CancellationToken cancellationToken) + { + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + if (_loadedProject == null) + { + return null; + } + + var projectFactory = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory; + var workspace = projectFactory.Workspace; + var project = workspace.CurrentSolution.GetProject(_loadedProject.ProjectSystemProject.Id); + if (project == null) + { + _logger.LogError("Canonical project not found in workspace"); + return null; + } + + // Add the source file to the project + _loadedProject.ProjectSystemProject.AddSourceFile(filePath); + + // Get the updated project and document + var updatedProject = workspace.CurrentSolution.GetProject(_loadedProject.ProjectSystemProject.Id); + var document = updatedProject?.Documents.FirstOrDefault(d => d.FilePath == filePath); + + return document; + } + } + + /// + /// Removes a document from the canonical project. + /// + public async Task RemoveDocumentAsync(string filePath, CancellationToken cancellationToken) + { + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + if (_loadedProject == null) + { + return false; + } + + var projectFactory = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory; + var workspace = projectFactory.Workspace; + var project = workspace.CurrentSolution.GetProject(_loadedProject.ProjectSystemProject.Id); + if (project == null) + { + return false; + } + + var document = project.Documents.FirstOrDefault(d => d.FilePath == filePath); + if (document == null) + { + return false; + } + + // Remove the source file from the project + _loadedProject.ProjectSystemProject.RemoveSourceFile(filePath); + + return true; + } + } + + private string CreateCanonicalProjectContent() + { + var artifactsPath = GetCanonicalProjectDirectory(); + var targetFramework = Environment.GetEnvironmentVariable("DOTNET_RUN_FILE_TFM") ?? "net$(BundledNETCoreAppTargetFrameworkVersion)"; + + var virtualProjectXml = $""" + + + {SecurityElement.Escape(artifactsPath)}\obj\ + {SecurityElement.Escape(artifactsPath)}\bin\ + + + + + Library + {SecurityElement.Escape(targetFramework)} + enable + enable + + + false + + + preview + + + + + + + + + + + + + + <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" /> + + + + + + + """; + + return virtualProjectXml; + } + + public void Dispose() + { + _loadedProject?.Dispose(); + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index e49078d1fe8b9..5a8770441e1d3 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -12,9 +12,11 @@ using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Threading; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.Extensions.Logging; using Roslyn.LanguageServer.Protocol; +using Roslyn.Utilities; using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager; namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; @@ -25,6 +27,9 @@ internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoad private readonly ILspServices _lspServices; private readonly ILogger _logger; private readonly VirtualProjectXmlProvider _projectXmlProvider; + private readonly IFileChangeWatcher _fileChangeWatcher; + private CanonicalMiscFilesProject? _canonicalMiscFilesProject; + private readonly SemaphoreSlim _canonicalProjectGate = new(initialCount: 1); public FileBasedProgramsProjectSystem( ILspServices lspServices, @@ -50,17 +55,56 @@ public FileBasedProgramsProjectSystem( _lspServices = lspServices; _logger = loggerFactory.CreateLogger(); _projectXmlProvider = projectXmlProvider; + _fileChangeWatcher = fileChangeWatcher; } private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString; + private async ValueTask AddMiscellaneousDocumentUsingCanonicalProjectAsync(string documentFilePath, SourceText documentText, CancellationToken cancellationToken) + { + using (await _canonicalProjectGate.DisposableWaitAsync(cancellationToken)) + { + // Initialize the canonical project on first use + if (_canonicalMiscFilesProject == null) + { + _canonicalMiscFilesProject = new CanonicalMiscFilesProject(_workspaceFactory, _logger); + + // Perform the initial design-time build + // Note: We pass null for binaryLogPathProvider since it's private in the base class. + // Binary logging is optional and primarily used for diagnostics. + await using var buildHostProcessManager = new BuildHostProcessManager(globalMSBuildProperties: AdditionalProperties, binaryLogPathProvider: null, loggerFactory: LoggerFactory); + var loadedProject = await _canonicalMiscFilesProject.EnsureInitializedAsync(buildHostProcessManager, _fileChangeWatcher, AdditionalProperties, cancellationToken); + + if (loadedProject == null) + { + _logger.LogError("Failed to initialize canonical miscellaneous files project. Falling back to per-file approach."); + _canonicalMiscFilesProject = null; + return null; + } + } + + // Add the document to the canonical project + var document = await _canonicalMiscFilesProject.AddDocumentAsync(documentFilePath, documentText, cancellationToken); + return document; + } + } + public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken) { - // There are two cases here: if it's a primordial document, it'll be in the MiscellaneousFilesWorkspace and thus we definitely know it's - // a miscellaneous file. Otherwise, it might be a file-based program that we loaded in the main workspace; in this case, the project's path + // Check if it's in the miscellaneous files workspace (either canonical project or primordial) + if (document.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace) + { + return true; + } + + // Check if it's a file-based program that we loaded in the main workspace; in this case, the project's path // is also the source file path, and that's what we consider the 'project' path that is loaded. - return document.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace || - document.Project.FilePath is not null && await IsProjectLoadedAsync(document.Project.FilePath, cancellationToken); + if (document.Project.FilePath is not null && await IsProjectLoadedAsync(document.Project.FilePath, cancellationToken)) + { + return true; + } + + return false; } public async ValueTask AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger) @@ -72,12 +116,32 @@ public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument docu Contract.Fail($"Could not find language information for {uri} with absolute path {documentFilePath}"); } - var primordialDoc = AddPrimordialDocument(uri, documentText, languageId); - Contract.ThrowIfNull(primordialDoc.FilePath); - var doDesignTimeBuild = uri.ParsedUri?.IsFile is true && languageInformation.LanguageName == LanguageNames.CSharp && GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms); + + // Check if this is a file-based program by parsing the text + bool isFileBasedProgram = false; + if (doDesignTimeBuild) + { + isFileBasedProgram = VirtualProjectXmlProvider.IsFileBasedProgram(documentFilePath, documentText); + } + + // For genuine miscellaneous files (not file-based programs), try to use the canonical project + if (doDesignTimeBuild && !isFileBasedProgram) + { + var canonicalDocument = await AddMiscellaneousDocumentUsingCanonicalProjectAsync(documentFilePath, documentText, cancellationToken: CancellationToken.None); + if (canonicalDocument != null) + { + return canonicalDocument; + } + // If canonical project initialization failed, fall through to use the existing approach + } + + // For file-based programs or when the feature is disabled, use the existing approach + var primordialDoc = AddPrimordialDocument(uri, documentText, languageId); + Contract.ThrowIfNull(primordialDoc.FilePath); + await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild); return primordialDoc; @@ -107,6 +171,21 @@ TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, str public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri) { var documentPath = GetDocumentFilePath(uri); + + // First try to remove from the canonical project + using (await _canonicalProjectGate.DisposableWaitAsync(CancellationToken.None)) + { + if (_canonicalMiscFilesProject != null) + { + var removedFromCanonical = await _canonicalMiscFilesProject.RemoveDocumentAsync(documentPath, CancellationToken.None); + if (removedFromCanonical) + { + return true; + } + } + } + + // If not in canonical project, try the per-file approach return await TryUnloadProjectAsync(documentPath); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs index 6f5dc4fdf3300..0f75d44c351b9 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs @@ -26,6 +26,7 @@ internal sealed class LoadedProject : IDisposable private readonly string _projectDirectory; private readonly ProjectSystemProject _projectSystemProject; + public ProjectSystemProject ProjectSystemProject => _projectSystemProject; public ProjectSystemProjectFactory ProjectFactory { get; } private readonly ProjectSystemProjectOptionsProcessor _optionsProcessor; private readonly IFileChangeContext _sourceFileChangeContext;