File: FileBasedPrograms\FileBasedProgramsProjectSystem.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 Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Features.Workspaces;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.ProjectSystem;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.Extensions.Logging;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
 
/// <summary>Handles loading both miscellaneous files and file-based program projects.</summary>
internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoader, ILspMiscellaneousFilesWorkspaceProvider, IDisposable
{
    private readonly ILspServices _lspServices;
    private readonly ILogger<FileBasedProgramsProjectSystem> _logger;
    private readonly VirtualProjectXmlProvider _projectXmlProvider;
    private readonly CanonicalMiscellaneousFilesProjectProvider _canonicalProjectProvider;
 
    public FileBasedProgramsProjectSystem(
        ILspServices lspServices,
        VirtualProjectXmlProvider projectXmlProvider,
        LanguageServerWorkspaceFactory workspaceFactory,
        IFileChangeWatcher fileChangeWatcher,
        IGlobalOptionService globalOptionService,
        ILoggerFactory loggerFactory,
        IAsynchronousOperationListenerProvider listenerProvider,
        ProjectLoadTelemetryReporter projectLoadTelemetry,
        ServerConfigurationFactory serverConfigurationFactory,
        IBinLogPathProvider binLogPathProvider,
        DotnetCliHelper dotnetCliHelper)
            : base(
                workspaceFactory,
                fileChangeWatcher,
                globalOptionService,
                loggerFactory,
                listenerProvider,
                projectLoadTelemetry,
                serverConfigurationFactory,
                binLogPathProvider,
                dotnetCliHelper)
    {
        _lspServices = lspServices;
        _logger = loggerFactory.CreateLogger<FileBasedProgramsProjectSystem>();
        _projectXmlProvider = projectXmlProvider;
        _canonicalProjectProvider = new CanonicalMiscellaneousFilesProjectProvider(workspaceFactory, loggerFactory);
 
        globalOptionService.AddOptionChangedHandler(this, OnGlobalOptionChanged);
    }
 
    public void Dispose()
    {
        GlobalOptionService.RemoveOptionChangedHandler(this, OnGlobalOptionChanged);
    }
 
    private void OnGlobalOptionChanged(object sender, object target, OptionChangedEventArgs args)
    {
        foreach (var (key, value) in args.ChangedOptions)
        {
            if (key.Option.Equals(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms))
            {
                // This event handler can't be async, so we ignore the resulting task here,
                // and take care that the ignored call doesn't throw an exception
                _ = HandleEnableFileBasedProgramsChangedAsync((bool)value!);
                break;
            }
        }
 
        async Task HandleEnableFileBasedProgramsChangedAsync(bool value)
        {
            using var token = Listener.BeginAsyncOperation(nameof(HandleEnableFileBasedProgramsChangedAsync));
            try
            {
                _logger.LogDebug($"Detected enableFileBasedPrograms changed to '{value}'. Unloading loose file projects.");
                await UnloadAllProjectsAsync();
            }
            catch (Exception ex) when (FatalError.ReportAndCatch(ex, ErrorSeverity.General))
            {
                throw ExceptionUtilities.Unreachable();
            }
        }
    }
 
    private static string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString;
 
    private bool ClassifyAsMiscellaneousFileWithNoReferences(string filePath, LanguageInformation languageInformation)
    {
        // 2. Is `enableFileBasedPrograms` enabled?
        //    - No → Classify as Miscellaneous File With No References
        //    - Yes → Continue to next check
        var enableFileBasedPrograms = GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms);
        if (!enableFileBasedPrograms)
        {
            return true;
        }
 
        // 3. Is the file a regular C# file? (i.e. not a `.csx` script, and not a file using a language besides C#)
        // - No → Classify as Miscellaneous File With No References
        // - Yes → Continue to next check
        if (languageInformation.LanguageName != LanguageNames.CSharp
            || MiscellaneousFileUtilities.IsScriptFile(languageInformation, filePath))
        {
            return true;
        }
 
        return false;
    }
 
    private async ValueTask<LooseDocumentKind> ClassifyDocumentAsync(string filePath, string languageId, CancellationToken cancellationToken)
    {
        var languageInfoProvider = _lspServices.GetRequiredService<ILanguageInfoProvider>();
        if (!languageInfoProvider.TryGetLanguageInformation(ProtocolConversions.CreateAbsoluteDocumentUri(filePath), languageId, out var languageInformation))
        {
            Contract.Fail($"Could not find language information for '{filePath}'");
        }
 
        // The design of this is described in docs/features/file-based-programs-vscode.md
        // Note: Step (1) is skipped, as we assume a first-chance lookup in the host workspace will handle this case.
 
        // Steps (2) and (3)
        if (ClassifyAsMiscellaneousFileWithNoReferences(filePath, languageInformation))
        {
            return LooseDocumentKind.MiscellaneousFileWithNoReferences;
        }
 
        // 4. Does the file have an absolute path and exist on disk? (i.e. it is not a "virtual document" created for a new, not-yet-saved file, or similar.)
        // - Yes → Go to (5)
        // - No → Classify as Miscellaneous File With Standard References
        if (!PathUtilities.IsAbsolute(filePath))
            return LooseDocumentKind.MiscellaneousFileWithStandardReferences;
 
        SourceText? sourceText = IOUtilities.PerformIO(() =>
        {
            // Note: SourceText.From eagerly reads the entire file
            using var fileStream = File.OpenRead(filePath);
            return SourceText.From(fileStream);
        });
 
        // File had an absolute path but we were unable to read it, due to it not existing or to some other I/O issue.
        if (sourceText is null)
        {
            return LooseDocumentKind.MiscellaneousFileWithStandardReferences;
        }
 
        // 5. Does the file have `#:` or `#!` directives?
        // - Yes → Classify as File-Based App. Restore if needed and show semantic errors.
        // - No → Continue to next check
        if (VirtualProjectXmlProvider.HasFileBasedAppDirectives(sourceText))
        {
            return LooseDocumentKind.FileBasedApp;
        }
 
        // 6. Is `enableFileBasedProgramsWhenAmbiguous` enabled? (default: `false` in release, `true` in prerelease)
        // - No → Classify as Miscellaneous File With Standard References
        // - Yes → Continue to heuristic detection
 
        if (!GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableSemanticErrorsInMiscellaneousFiles))
        {
            return LooseDocumentKind.MiscellaneousFileWithStandardReferences;
        }
 
        // Heuristic Detection:
 
        // 7. Are top-level statements present?
        // - No → Classify as Miscellaneous File With Standard References
        // - Yes → Continue to next check
 
        var syntaxTree = CSharpSyntaxTree.ParseText(sourceText, cancellationToken: cancellationToken);
        var containsTopLevelStatements = syntaxTree.GetRoot(cancellationToken) is CompilationUnitSyntax compilationUnit && compilationUnit.Members.Any(SyntaxKind.GlobalStatement);
        if (!containsTopLevelStatements)
        {
            return LooseDocumentKind.MiscellaneousFileWithStandardReferences;
        }
 
        // 8. Is the file included in a `.csproj` cone?
        // - Yes → Classify as Miscellaneous File With Standard References (wait for project to load)
        // - No → Classify as Miscellaneous File With Standard References and Semantic Errors
        var csprojInConeChecker = _lspServices.GetRequiredService<CsprojInConeChecker>();
        if (csprojInConeChecker.IsContainedInCsprojCone(filePath))
        {
            return LooseDocumentKind.MiscellaneousFileWithStandardReferences;
        }
 
        return LooseDocumentKind.MiscellaneousFileWithStandardReferencesAndSemanticErrors;
    }
 
    public async ValueTask<TextDocument?> AddDocumentAsync(DocumentUri documentUri, TrackedDocumentInfo documentInfo)
    {
        var languageInfoProvider = _lspServices.GetRequiredService<ILanguageInfoProvider>();
        if (!languageInfoProvider.TryGetLanguageInformation(documentUri, documentInfo.LanguageId, out var languageInformation))
        {
            Contract.Fail($"Could not find language information for '{documentUri}'");
        }
 
        var documentFilePath = GetDocumentFilePath(documentUri);
        var projectFactory = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory;
        var workspace = _workspaceFactory.MiscellaneousFilesWorkspace;
        var sourceTextLoader = new SourceTextLoader(documentInfo.SourceText, documentFilePath);
        var enableFileBasedPrograms = GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms);
        var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument(
            workspace, documentFilePath, sourceTextLoader, languageInformation, documentInfo.SourceText.ChecksumAlgorithm, workspace.Services.SolutionServices, [], enableFileBasedPrograms);
        projectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo));
        var id = projectInfo.Documents.Single().Id;
        var primordialDoc = workspace.CurrentSolution.GetRequiredDocument(id);
        var doDesignTimeBuild = !ClassifyAsMiscellaneousFileWithNoReferences(documentFilePath, languageInformation);
        await BeginLoadingProjectWithPrimordialAsync(documentFilePath, projectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild);
        return primordialDoc;
    }
 
    public async ValueTask<bool> TryRemoveMiscellaneousDocumentAsync(DocumentUri uri)
    {
        // Note: we intentionally do not unload file-based apps in this path.
        // This is because we want to unload from the miscellaneous files workspace only, when a file is found in the host workspace.
        var documentPath = GetDocumentFilePath(uri);
        return await TryUnloadProjectAsync(documentPath, fromProjectFactory: _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory);
    }
 
    public async ValueTask CloseDocumentAsync(DocumentUri uri)
    {
        var documentPath = GetDocumentFilePath(uri);
        await TryUnloadProjectAsync(documentPath);
    }
 
    protected override async Task<RemoteProjectLoadResult?> TryLoadProjectInMSBuildHostAsync(
        BuildHostProcessManager buildHostProcessManager, string documentPath, CancellationToken cancellationToken)
    {
        // Note: we assume that if we made it this far, the document is for the C# language.
        var documentKind = await ClassifyDocumentAsync(documentPath, languageId: "csharp", cancellationToken);
        _logger.LogDebug("Classified '{documentPath}' as '{documentKind}'.", documentPath, documentKind);
 
        if (documentKind == LooseDocumentKind.MiscellaneousFileWithNoReferences)
        {
            // This might happen due to a race involving changes to option values.
            // Just don't proceed with the reload and assume the option change handler will unload this project if needed.
            _logger.LogWarning("A document classified as {documentKind} should not be design-time built.", documentKind);
            return null;
        }
 
        if (documentKind is LooseDocumentKind.MiscellaneousFileWithStandardReferences or LooseDocumentKind.MiscellaneousFileWithStandardReferencesAndSemanticErrors)
        {
            return new RemoteProjectLoadResult
            {
                ProjectFileInfos = await _canonicalProjectProvider.GetProjectInfoAsync(documentPath, cancellationToken).ConfigureAwait(false),
                DiagnosticLogItems = [],
                ProjectFactory = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory,
                IsFileBasedProgram = false,
                IsMiscellaneousFile = true,
                HasAllInformation = documentKind is LooseDocumentKind.MiscellaneousFileWithStandardReferencesAndSemanticErrors,
                PreferredBuildHostKind = BuildHostProcessKind.NetCore,
                ActualBuildHostKind = BuildHostProcessKind.NetCore,
            };
        }
 
        // Fall through to ordinary file-based app handling.
        Contract.ThrowIfFalse(documentKind is LooseDocumentKind.FileBasedApp);
 
        var content = await _projectXmlProvider.GetVirtualProjectContentAsync(documentPath, _logger, cancellationToken);
        if (content is not var (virtualProjectContent, diagnostics))
        {
            // https://github.com/dotnet/roslyn/issues/78618: falling back to this until dotnet run-api is more widely available
            _logger.LogInformation($"Failed to obtain virtual project for '{documentPath}' using dotnet run-api. Falling back to directly creating the virtual project.");
            virtualProjectContent = VirtualProjectXmlProvider.MakeVirtualProjectContent_DirectFallback(documentPath);
            diagnostics = [];
        }
 
        foreach (var diagnostic in diagnostics)
        {
            _logger.LogError($"{diagnostic.Location.Path}{diagnostic.Location.Span.Start}: {diagnostic.Message}");
        }
 
        // When loading a virtual project, the path to the on-disk source file is not used. Instead the path is adjusted to end with .csproj.
        // This is necessary in order to get msbuild to apply the standard c# props/targets to the project.
        var virtualProjectPath = VirtualProjectXmlProvider.GetVirtualProjectPath(documentPath);
        const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore;
        var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, virtualProjectPath, dotnetPath: null, cancellationToken);
        var loadedFile = await buildHost.LoadProjectAsync(virtualProjectPath, virtualProjectContent, languageName: LanguageNames.CSharp, cancellationToken);
 
        return new RemoteProjectLoadResult
        {
            ProjectFileInfos = await loadedFile.GetProjectFileInfosAsync(cancellationToken),
            DiagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken),
            ProjectFactory = _workspaceFactory.HostProjectFactory,
            IsFileBasedProgram = true,
            IsMiscellaneousFile = false,
            HasAllInformation = true,
            PreferredBuildHostKind = buildHostKind,
            ActualBuildHostKind = buildHostKind,
        };
    }
}