Skip to content

Invoke dotnet run-api to obtain virtual project #78648

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 28, 2025
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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
Comment on lines +35 to +40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a bug tracking enabling this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


public override string SkipReason => $"Compilation symbol 'RoslynTestRunApi' is not defined.";
}

private async Task<VirtualProjectXmlProvider> GetProjectXmlProviderAsync()
{
var (exportProvider, _) = await LanguageServerTestComposition.CreateExportProviderAsync(
LoggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, extensionPaths: null);
return exportProvider.GetExportedValue<VirtualProjectXmlProvider>();
}

[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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these tests should fail in CI (and look like they are). We would need to figure out some way of ensuring the preview SDK is available in helix machines.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to keep things moving forward by just skipping the tests requiring preview SDK in CI.

}
}
""");

var contentNullable = await projectProvider.GetVirtualProjectContentAsync(appFile.Path, CancellationToken.None);
var content = contentNullable.Value;
var virtualProjectXml = content.VirtualProjectXml;
LoggerFactory.CreateLogger<VirtualProjectXmlProviderTests>().LogTrace(virtualProjectXml);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the xunit logging support?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base type is adding a logger provider which wraps the xunit logger. It didn't feel necessary to directly use the xunit logger given that.


Assert.Contains("<TargetFramework>net10.0</TargetFramework>", virtualProjectXml);
Assert.Contains("<ArtifactsPath>", 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<VirtualProjectXmlProviderTests>().LogTrace(content.VirtualProjectXml);

Assert.Contains("<TargetFramework>net10.0</TargetFramework>", content.VirtualProjectXml);
Assert.Contains("<ArtifactsPath>", 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 [email protected]
#: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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we should fix that now, since otherwise we're reporting bad locations to the error list?

Copy link
Member Author

@RikkiGibson RikkiGibson May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing is including the diagnostics from run-api in the error list currently.

Assert.Equal("(0,0)-(0,0)", diagnostic.Location.Span.ToString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ private async Task<string> 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}");

var startInfo = new ProcessStartInfo(_dotnetExecutablePath.Value)
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardInput = redirectStandardInput,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this frankly always be true as well? Because it seems either that:

  1. The command we're running won't read stdin (so it won't really matter).
  2. The command we might be running might incidentally read stdin (like restore triggering an interactive credential provider) and we probably don't want it using some random stdin from...somewhere?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case (2), I think it would essentially read the stdin of the parent process, which it seems like we wouldn't want.

RedirectStandardOutput = true,
RedirectStandardError = true,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,18 +24,20 @@
using Roslyn.Utilities;
using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager;

namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;

/// <summary>Handles loading both miscellaneous files and file-based program projects.</summary>
internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoader, ILspMiscellaneousFilesWorkspaceProvider
{
private readonly ILspServices _lspServices;
private readonly ILogger<FileBasedProgramsProjectSystem> _logger;
private readonly IMetadataAsSourceFileService _metadataAsSourceFileService;
private readonly VirtualProjectXmlProvider _projectXmlProvider;

public FileBasedProgramsProjectSystem(
ILspServices lspServices,
IMetadataAsSourceFileService metadataAsSourceFileService,
VirtualProjectXmlProvider projectXmlProvider,
LanguageServerWorkspaceFactory workspaceFactory,
IFileChangeWatcher fileChangeWatcher,
IGlobalOptionService globalOptionService,
Expand All @@ -57,6 +61,7 @@ public FileBasedProgramsProjectSystem(
_lspServices = lspServices;
_logger = loggerFactory.CreateLogger<FileBasedProgramsProjectSystem>();
_metadataAsSourceFileService = metadataAsSourceFileService;
_projectXmlProvider = projectXmlProvider;
}

public Workspace Workspace => ProjectFactory.Workspace;
Expand Down Expand Up @@ -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<RemoteProjectLoadResult?> 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be clearer then to pass in the ILogger so that way it's clear it's expected to log to this rather than having it's own logger.

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the name of the file also come from the API too?


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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,7 +16,7 @@
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.Extensions.Logging;

namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;

/// <summary>
/// Service to create <see cref="LspMiscellaneousFilesWorkspaceProvider"/> instances.
Expand All @@ -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,
Expand All @@ -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);
}
}
Loading
Loading