diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VirtualProjectXmlProviderTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VirtualProjectXmlProviderTests.cs new file mode 100644 index 0000000000000..b439d7672ad3d --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VirtualProjectXmlProviderTests.cs @@ -0,0 +1,189 @@ +// 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. + +// Uncomment this to test run-api locally. +// Eventually when a new enough SDK is adopted in-repo we can remove this +//#define RoslynTestRunApi + +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; +using Microsoft.Extensions.Logging; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Test.Utilities; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; + +/// +/// Goal of these tests: +/// - Ensure that the various request/response forms work as expected in basic scenarios. +/// - Ensure that various properties on the response are populated in a reasonable way. +/// Non-goals: +/// - Thorough behavioral testing. +/// - Testing of more intricate behaviors which are subject to change. +/// +public sealed class VirtualProjectXmlProviderTests : AbstractLanguageServerHostTests +{ + public VirtualProjectXmlProviderTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + } + + private class EnableRunApiTests : ExecutionCondition + { + public override bool ShouldSkip => +#if RoslynTestRunApi + false; +#else + true; +#endif + + public override string SkipReason => $"Compilation symbol 'RoslynTestRunApi' is not defined."; + } + + private async Task GetProjectXmlProviderAsync() + { + var (exportProvider, _) = await LanguageServerTestComposition.CreateExportProviderAsync( + LoggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, extensionPaths: null); + return exportProvider.GetExportedValue(); + } + + [Fact] + public async Task GetProjectXml_FileBasedProgram_SdkTooOld_01() + { + var projectProvider = await GetProjectXmlProviderAsync(); + + var tempDir = TempRoot.CreateDirectory(); + var appFile = tempDir.CreateFile("app.cs"); + await appFile.WriteAllTextAsync(""" + Console.WriteLine("Hello, world!"); + """); + + var globalJsonFile = tempDir.CreateFile("global.json"); + globalJsonFile.WriteAllBytes(Encoding.UTF8.GetBytes(""" + { + "sdk": { + "version": "9.0.105" + } + } + """)); + + var contentNullable = await projectProvider.GetVirtualProjectContentAsync(appFile.Path, CancellationToken.None); + Assert.Null(contentNullable); + } + + [ConditionalFact(typeof(EnableRunApiTests))] + public async Task GetProjectXml_FileBasedProgram_01() + { + var projectProvider = await GetProjectXmlProviderAsync(); + + var tempDir = TempRoot.CreateDirectory(); + var appFile = tempDir.CreateFile("app.cs"); + await appFile.WriteAllTextAsync(""" + Console.WriteLine("Hello, world!"); + """); + + var globalJsonFile = tempDir.CreateFile("global.json"); + await globalJsonFile.WriteAllTextAsync(""" + { + "sdk": { + "version": "10.0.100-preview.5.25265.12" + } + } + """); + + var contentNullable = await projectProvider.GetVirtualProjectContentAsync(appFile.Path, CancellationToken.None); + var content = contentNullable.Value; + var virtualProjectXml = content.VirtualProjectXml; + LoggerFactory.CreateLogger().LogTrace(virtualProjectXml); + + Assert.Contains("net10.0", virtualProjectXml); + Assert.Contains("", virtualProjectXml); + Assert.Empty(content.Diagnostics); + } + + [ConditionalFact(typeof(EnableRunApiTests))] + public async Task GetProjectXml_NonFileBasedProgram_01() + { + var projectProvider = await GetProjectXmlProviderAsync(); + + var tempDir = TempRoot.CreateDirectory(); + var appFile = tempDir.CreateFile("app.cs"); + await appFile.WriteAllTextAsync(""" + public class C + { + } + """); + + var globalJsonFile = tempDir.CreateFile("global.json"); + await globalJsonFile.WriteAllTextAsync(""" + { + "sdk": { + "version": "10.0.100-preview.5.25265.12" + } + } + """); + + var contentNullable = await projectProvider.GetVirtualProjectContentAsync(appFile.Path, CancellationToken.None); + var content = contentNullable.Value; + LoggerFactory.CreateLogger().LogTrace(content.VirtualProjectXml); + + Assert.Contains("net10.0", content.VirtualProjectXml); + Assert.Contains("", content.VirtualProjectXml); + Assert.Empty(content.Diagnostics); + } + + [ConditionalFact(typeof(EnableRunApiTests))] + public async Task GetProjectXml_BadPath_01() + { + var projectProvider = await GetProjectXmlProviderAsync(); + + var tempDir = TempRoot.CreateDirectory(); + + var globalJsonFile = tempDir.CreateFile("global.json"); + await globalJsonFile.WriteAllTextAsync(""" + { + "sdk": { + "version": "10.0.100-preview.5.25265.12" + } + } + """); + + var content = await projectProvider.GetVirtualProjectContentAsync(Path.Combine(tempDir.Path, "BAD"), CancellationToken.None); + Assert.Null(content); + } + + [ConditionalFact(typeof(EnableRunApiTests))] + public async Task GetProjectXml_BadDirective_01() + { + var projectProvider = await GetProjectXmlProviderAsync(); + + var tempDir = TempRoot.CreateDirectory(); + var appFile = tempDir.CreateFile("app.cs"); + await appFile.WriteAllTextAsync(""" + #:package Newtonsoft.Json@13.0.3 + #:BAD + Console.WriteLine("Hello, world!"); + """); + + var globalJsonFile = tempDir.CreateFile("global.json"); + await globalJsonFile.WriteAllTextAsync(""" + { + "sdk": { + "version": "10.0.100-preview.5.25265.12" + } + } + """); + + var contentNullable = await projectProvider.GetVirtualProjectContentAsync(appFile.Path, CancellationToken.None); + var content = contentNullable.Value; + var diagnostic = content.Diagnostics.Single(); + Assert.Contains("Unrecognized directive 'BAD'", diagnostic.Message); + Assert.Equal(appFile.Path, diagnostic.Location.Path); + + // LinePositionSpan is not deserializing properly. + // Address when implementing editor squiggles. https://github.com/dotnet/roslyn/issues/78688 + Assert.Equal("(0,0)-(0,0)", diagnostic.Location.Span.ToString()); + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/DotnetCliHelper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/DotnetCliHelper.cs index c2d0c09b8c067..3b9d6766e950b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/DotnetCliHelper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/DotnetCliHelper.cs @@ -68,7 +68,7 @@ private async Task GetDotnetSdkFolderFromDotnetExecutableAsync(string pr return dotnetSdkFolderPath; } - public Process Run(string[] arguments, string? workingDirectory, bool shouldLocalizeOutput) + public Process Run(string[] arguments, string? workingDirectory, bool shouldLocalizeOutput, bool redirectStandardInput = false) { _logger.LogDebug($"Running dotnet CLI command at {_dotnetExecutablePath.Value} in directory {workingDirectory} with arguments {arguments}"); @@ -76,6 +76,7 @@ public Process Run(string[] arguments, string? workingDirectory, bool shouldLoca { CreateNoWindow = true, UseShellExecute = false, + RedirectStandardInput = redirectStandardInput, RedirectStandardOutput = true, RedirectStandardError = true, }; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs similarity index 83% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsProjectSystem.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index f60b31557eeef..981095c2d0817 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -4,8 +4,10 @@ using System.Collections.Immutable; using System.Security; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Features.Workspaces; using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.MSBuild; @@ -22,7 +24,7 @@ using Roslyn.Utilities; using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager; -namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; /// Handles loading both miscellaneous files and file-based program projects. internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoader, ILspMiscellaneousFilesWorkspaceProvider @@ -30,10 +32,12 @@ internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoad private readonly ILspServices _lspServices; private readonly ILogger _logger; private readonly IMetadataAsSourceFileService _metadataAsSourceFileService; + private readonly VirtualProjectXmlProvider _projectXmlProvider; public FileBasedProgramsProjectSystem( ILspServices lspServices, IMetadataAsSourceFileService metadataAsSourceFileService, + VirtualProjectXmlProvider projectXmlProvider, LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, IGlobalOptionService globalOptionService, @@ -57,6 +61,7 @@ public FileBasedProgramsProjectSystem( _lspServices = lspServices; _logger = loggerFactory.CreateLogger(); _metadataAsSourceFileService = metadataAsSourceFileService; + _projectXmlProvider = projectXmlProvider; } public Workspace Workspace => ProjectFactory.Workspace; @@ -124,20 +129,33 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool await UnloadProjectAsync(documentPath); } - protected override async Task<(RemoteProjectFile projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)?> TryLoadProjectInMSBuildHostAsync( + protected override async Task TryLoadProjectInMSBuildHostAsync( BuildHostProcessManager buildHostProcessManager, string documentPath, CancellationToken cancellationToken) { - const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore; - var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken); + var content = await _projectXmlProvider.GetVirtualProjectContentAsync(documentPath, cancellationToken); + if (content is not var (virtualProjectContent, diagnostics)) + { + // 'GetVirtualProjectContentAsync' will log errors when it fails + return null; + } - var loader = ProjectFactory.CreateFileTextLoader(documentPath); - var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken); - var (virtualProjectContent, isFileBasedProgram) = VirtualCSharpFileBasedProgramProject.MakeVirtualProjectContent(documentPath, textAndVersion.Text); + foreach (var diagnostic in diagnostics) + { + // https://github.com/dotnet/roslyn/issues/78688: Surface diagnostics in editor + _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 = VirtualCSharpFileBasedProgramProject.GetVirtualProjectPath(documentPath); + var virtualProjectPath = VirtualProjectXmlProvider.GetVirtualProjectPath(documentPath); + + var loader = ProjectFactory.CreateFileTextLoader(documentPath); + var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken); + var isFileBasedProgram = VirtualProjectXmlProvider.IsFileBasedProgram(documentPath, textAndVersion.Text); + + const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore; + var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken); var loadedFile = await buildHost.LoadProjectAsync(virtualProjectPath, virtualProjectContent, languageName: LanguageNames.CSharp, cancellationToken); - return (loadedFile, hasAllInformation: isFileBasedProgram, preferred: buildHostKind, actual: buildHostKind); + return new RemoteProjectLoadResult(loadedFile, HasAllInformation: isFileBasedProgram, Preferred: buildHostKind, Actual: buildHostKind); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsWorkspaceProviderFactory.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs similarity index 77% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsWorkspaceProviderFactory.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs index de90d3175e244..3754d38086a01 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/FileBasedProgramsWorkspaceProviderFactory.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs @@ -6,6 +6,7 @@ using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.MSBuild; @@ -15,7 +16,7 @@ using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.Extensions.Logging; -namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; /// /// Service to create instances. @@ -27,6 +28,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class FileBasedProgramsWorkspaceProviderFactory( IMetadataAsSourceFileService metadataAsSourceFileService, + VirtualProjectXmlProvider projectXmlProvider, LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, IGlobalOptionService globalOptionService, @@ -38,6 +40,17 @@ internal sealed class FileBasedProgramsWorkspaceProviderFactory( { public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices) { - return new FileBasedProgramsProjectSystem(lspServices, metadataAsSourceFileService, workspaceFactory, fileChangeWatcher, globalOptionService, loggerFactory, listenerProvider, projectLoadTelemetry, serverConfigurationFactory, binLogPathProvider); + return new FileBasedProgramsProjectSystem( + lspServices, + metadataAsSourceFileService, + projectXmlProvider, + workspaceFactory, + fileChangeWatcher, + globalOptionService, + loggerFactory, + listenerProvider, + projectLoadTelemetry, + serverConfigurationFactory, + binLogPathProvider); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/RunApiModels.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/RunApiModels.cs new file mode 100644 index 0000000000000..91dfc531e14e2 --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/RunApiModels.cs @@ -0,0 +1,75 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms +{ + [JsonDerivedType(typeof(GetProject), nameof(GetProject))] + internal abstract class RunApiInput + { + private RunApiInput() { } + + public sealed class GetProject : RunApiInput + { + public string? ArtifactsPath { get; init; } + public required string EntryPointFileFullPath { get; init; } + } + } + + [JsonDerivedType(typeof(Error), nameof(Error))] + [JsonDerivedType(typeof(Project), nameof(Project))] + internal abstract class RunApiOutput + { + private RunApiOutput() { } + + public const int LatestKnownVersion = 1; + + [JsonPropertyOrder(-1)] + public int Version { get; } + + public sealed class Error : RunApiOutput + { + public required string Message { get; init; } + public required string Details { get; init; } + } + + public sealed class Project : RunApiOutput + { + public required string Content { get; init; } + public required ImmutableArray Diagnostics { get; init; } + } + } + internal sealed class SimpleDiagnostic + { + public required Position Location { get; init; } + public required string Message { get; init; } + + /// + /// An adapter of that ensures we JSON-serialize only the necessary fields. + /// + public readonly struct Position + { + public string Path { get; init; } + public LinePositionSpan Span { get; init; } + + public static implicit operator Position(FileLinePositionSpan fileLinePositionSpan) => new() + { + Path = fileLinePositionSpan.Path, + Span = fileLinePositionSpan.Span, + }; + } + } + + [JsonSerializable(typeof(RunApiInput))] + [JsonSerializable(typeof(RunApiOutput))] + internal partial class RunFileApiJsonSerializerContext : JsonSerializerContext; +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs new file mode 100644 index 0000000000000..96e9a08c32fb0 --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs @@ -0,0 +1,109 @@ +// 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.Composition; +using System.Runtime.InteropServices; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Logging; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; + +[Export(typeof(VirtualProjectXmlProvider)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal class VirtualProjectXmlProvider(DotnetCliHelper dotnetCliHelper, ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + internal async Task<(string VirtualProjectXml, ImmutableArray Diagnostics)?> GetVirtualProjectContentAsync(string documentFilePath, CancellationToken cancellationToken) + { + var workingDirectory = Path.GetDirectoryName(documentFilePath); + var process = dotnetCliHelper.Run(["run-api"], workingDirectory, shouldLocalizeOutput: true, redirectStandardInput: true); + + cancellationToken.Register(() => + { + process?.Kill(); + }); + + var input = new RunApiInput.GetProject() { EntryPointFileFullPath = documentFilePath }; + var inputJson = JsonSerializer.Serialize(input, RunFileApiJsonSerializerContext.Default.RunApiInput); + await process.StandardInput.WriteAsync(inputJson); + process.StandardInput.Close(); + + // Debug severity is used for these because we think it will be common for the user environment to have too old of an SDK for the call to work. + // Rather than representing a hard error condition, it represents a condition where we need to gracefully downgrade the experience. + process.ErrorDataReceived += (sender, args) => _logger.LogDebug($"dotnet run-api: {args.Data}"); + process.BeginErrorReadLine(); + + var responseJson = await process.StandardOutput.ReadLineAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + _logger.LogDebug($"dotnet run-api exited with exit code '{process.ExitCode}'."); + return null; + } + + if (string.IsNullOrWhiteSpace(responseJson)) + { + _logger.LogError($"dotnet run-api exited with exit code 0, but did not return any response."); + return null; + } + + try + { + var response = JsonSerializer.Deserialize(responseJson, RunFileApiJsonSerializerContext.Default.RunApiOutput); + if (response is RunApiOutput.Error error) + { + _logger.LogError($"dotnet run-api version: {error.Version}. Latest known version: {RunApiOutput.LatestKnownVersion}"); + _logger.LogError($"dotnet run-api returned error: '{error.Message}'"); + return null; + } + + if (response is RunApiOutput.Project project) + { + if (project.Version > RunApiOutput.LatestKnownVersion) + { + _logger.LogWarning($"'dotnet run-api' version '{project.Version}' is newer than latest known version {RunApiOutput.LatestKnownVersion}"); + } + + return (project.Content, project.Diagnostics); + } + + throw ExceptionUtilities.UnexpectedValue(response); + } + catch (JsonException ex) + { + // In this case, run-api returned 0 exit code, but gave us back JSON that we don't know how to parse. + _logger.LogError(ex, "Could not deserialize run-api response."); + return null; + } + } + + /// + /// Adjusts a path to a file-based program for use in passing the virtual project to msbuild. + /// (msbuild needs the path to end in .csproj to recognize as a C# project and apply all the standard props/targets to it.) + /// + internal static string GetVirtualProjectPath(string documentFilePath) + => Path.ChangeExtension(documentFilePath, ".csproj"); + + internal static bool IsFileBasedProgram(string documentFilePath, SourceText text) + { + // TODO: this needs to be adjusted to be more sustainable. + // When we adopt the dotnet run-api, we need to get rid of this or adjust it to be more sustainable (e.g. using the appropriate document to get a syntax tree) + var tree = CSharpSyntaxTree.ParseText(text, options: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), path: documentFilePath); + var root = tree.GetRoot(); + var isFileBasedProgram = root.GetLeadingTrivia().Any(SyntaxKind.IgnoredDirectiveTrivia) || root.ChildNodes().Any(node => node.IsKind(SyntaxKind.GlobalStatement)); + return isFileBasedProgram; + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index b16e6c6ccf0db..3b4f7d4ce7044 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -180,9 +180,11 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedListLoads a project in the MSBuild host. /// Caller needs to catch exceptions to avoid bringing down the project loader queue. - protected abstract Task<(RemoteProjectFile projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)?> TryLoadProjectInMSBuildHostAsync( + protected abstract Task TryLoadProjectInMSBuildHostAsync( BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken); /// True if the project needs a NuGet restore, false otherwise. @@ -203,13 +205,17 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr try { - if (await TryLoadProjectInMSBuildHostAsync(buildHostProcessManager, projectPath, cancellationToken) - is not var (remoteProjectFile, hasAllInformation, preferredBuildHostKind, actualBuildHostKind)) + var remoteProjectLoadResult = await TryLoadProjectInMSBuildHostAsync(buildHostProcessManager, projectPath, cancellationToken); + if (remoteProjectLoadResult is null) { + // Note that this is a fairly common condition, e.g. for VB projects. + // In the file-based programs primordial case, no 'LoadedProject' is produced for the project, + // and therefore no reloading is performed for it after failing to load it once (in this code path). _logger.LogWarning($"Unable to load project '{projectPath}'."); return false; } + (RemoteProjectFile remoteProjectFile, bool hasAllInformation, BuildHostProcessKind preferredBuildHostKind, BuildHostProcessKind actualBuildHostKind) = remoteProjectLoadResult; if (preferredBuildHostKind != actualBuildHostKind) preferredBuildHostKindThatWeDidNotGet = preferredBuildHostKind; @@ -249,15 +255,10 @@ is not var (remoteProjectFile, hasAllInformation, preferredBuildHostKind, actual var (target, targetAlreadyExists) = await GetOrCreateProjectTargetAsync(previousProjectTargets, loadedProjectInfo); newProjectTargetsBuilder.Add(target); - if (targetAlreadyExists) - { - // https://github.com/dotnet/roslyn/issues/78561: Automatic restore should run even when the target is already loaded - _ = await target.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger); - } - else + var (targetTelemetryInfo, targetNeedsRestore) = await target.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger); + needsRestore |= targetNeedsRestore; + if (!targetAlreadyExists) { - var (targetTelemetryInfo, targetNeedsRestore) = await target.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger); - needsRestore |= targetNeedsRestore; telemetryInfos[loadedProjectInfo] = targetTelemetryInfo with { IsSdkStyle = preferredBuildHostKind == BuildHostProcessKind.NetCore }; } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs index 64d3082ef258f..47cc6a8462d3a 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs @@ -91,7 +91,7 @@ public async Task OpenProjectsAsync(ImmutableArray projectFilePaths) await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync(); } - protected override async Task<(RemoteProjectFile projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)?> TryLoadProjectInMSBuildHostAsync( + protected override async Task TryLoadProjectInMSBuildHostAsync( BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken) { if (!_projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, DiagnosticReportingMode.Ignore, out var languageName)) @@ -101,6 +101,6 @@ public async Task OpenProjectsAsync(ImmutableArray projectFilePaths) var (buildHost, actualBuildHostKind) = await buildHostProcessManager.GetBuildHostWithFallbackAsync(preferredBuildHostKind, projectPath, cancellationToken); var loadedFile = await buildHost.LoadProjectFileAsync(projectPath, languageName, cancellationToken); - return (loadedFile, hasAllInformation: true, preferredBuildHostKind, actualBuildHostKind); + return new RemoteProjectLoadResult(loadedFile, HasAllInformation: true, preferredBuildHostKind, actualBuildHostKind); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/VirtualProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/VirtualProject.cs deleted file mode 100644 index 8bada428d0f98..0000000000000 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/VirtualProject.cs +++ /dev/null @@ -1,148 +0,0 @@ -// 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.Runtime.InteropServices; -using System.Security; -using System.Security.Cryptography; -using System.Text; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using Roslyn.Utilities; - -namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; - -/// -/// This will be replaced invoke dotnet run-api command implemented in https://github.com/dotnet/sdk/pull/48749 -/// -internal static class VirtualCSharpFileBasedProgramProject -{ - /// - /// Adjusts a path to a file-based program for use in passing the virtual project to msbuild. - /// (msbuild needs the path to end in .csproj to recognize as a C# project and apply all the standard props/targets to it.) - /// - internal static string GetVirtualProjectPath(string documentFilePath) - => Path.ChangeExtension(documentFilePath, ".csproj"); - - #region TODO: Copy-pasted from dotnet run-api. Delete when run-api is adopted. - // See https://github.com/dotnet/sdk/blob/b5dbc69cc28676ac6ea615654c8016a11b75e747/src/Cli/Microsoft.DotNet.Cli.Utils/Sha256Hasher.cs#L10 - private static class Sha256Hasher - { - public static string Hash(string text) - { - byte[] bytes = Encoding.UTF8.GetBytes(text); - byte[] hash = SHA256.HashData(bytes); -#if NET9_0_OR_GREATER - return Convert.ToHexStringLower(hash); -#else - return Convert.ToHexString(hash).ToLowerInvariant(); -#endif - } - - public static string HashWithNormalizedCasing(string text) - { - return Hash(text.ToUpperInvariant()); - } - } - - // TODO: this is a copy of SDK run-api code. Must delete when adopting run-api. - // See https://github.com/dotnet/sdk/blob/5a4292947487a9d34f4256c1d17fb3dc26859174/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L449 - internal static string GetArtifactsPath(string entryPointFileFullPath) - { - // We want a location where permissions are expected to be restricted to the current user. - string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.GetTempPath() - : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - - // Include entry point file name so the directory name is not completely opaque. - string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath); - string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath); - string directoryName = $"{fileName}-{hash}"; - - return Path.Join(directory, "dotnet", "runfile", directoryName); - } - #endregion - - internal static (string virtualProjectXml, bool isFileBasedProgram) MakeVirtualProjectContent(string documentFilePath, SourceText text) - { - Contract.ThrowIfFalse(PathUtilities.IsAbsolute(documentFilePath)); - // NB: this is a temporary solution for running our heuristic. - // When we adopt the dotnet run-api, we need to get rid of this or adjust it to be more sustainable (e.g. using the appropriate document to get a syntax tree) - var tree = CSharpSyntaxTree.ParseText(text, options: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), path: documentFilePath); - var root = tree.GetRoot(); - var isFileBasedProgram = root.GetLeadingTrivia().Any(SyntaxKind.IgnoredDirectiveTrivia) || root.ChildNodes().Any(node => node.IsKind(SyntaxKind.GlobalStatement)); - - var artifactsPath = GetArtifactsPath(documentFilePath); - - var targetFramework = Environment.GetEnvironmentVariable("DOTNET_RUN_FILE_TFM") ?? "net10.0"; - - var virtualProjectXml = $""" - - - - false - {SecurityElement.Escape(artifactsPath)} - - - - - - - Exe - {SecurityElement.Escape(targetFramework)} - enable - enable - - - - false - - - - preview - - - - $(Features);FileBasedProgram - - - - - - - - - - - - - - - - - - - - <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" /> - - - - - - - - - """; - - return (virtualProjectXml, isFileBasedProgram); - } -}