Skip to content

Commit 98d30b2

Browse files
RikkiGibsonCyrusNajmabadijasonmalinowski
authored
File based programs IDE support (#78488)
Co-authored-by: Cyrus Najmabadi <[email protected]> Co-authored-by: Jason Malinowski <[email protected]>
1 parent 4215d71 commit 98d30b2

26 files changed

+727
-150
lines changed

.vscode/tasks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@
169169
"type": "process",
170170
"options": {
171171
"env": {
172-
"DOTNET_ROSLYN_SERVER_PATH": "${workspaceRoot}/artifacts/bin/Microsoft.CodeAnalysis.LanguageServer/Debug/net8.0/Microsoft.CodeAnalysis.LanguageServer.dll"
172+
"DOTNET_ROSLYN_SERVER_PATH": "${workspaceRoot}/artifacts/bin/Microsoft.CodeAnalysis.LanguageServer/Debug/net9.0/Microsoft.CodeAnalysis.LanguageServer.dll"
173173
}
174174
},
175175
"dependsOn": [ "build language server" ]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# File-based programs VS Code support
2+
3+
See also [dotnet-run-file.md](https://github.com/dotnet/sdk/blob/main/documentation/general/dotnet-run-file.md).
4+
5+
## Feature overview
6+
7+
A file-based program embeds a subset of MSBuild project capabilities into C# code, allowing single files to stand alone as ordinary projects.
8+
9+
The following is a file-based program:
10+
11+
```cs
12+
Console.WriteLine("Hello World!");
13+
```
14+
15+
So is the following:
16+
17+
```cs
18+
#!/usr/bin/env dotnet run
19+
#:sdk Microsoft.Net.Sdk
20+
21+
#:property LangVersion=preview
22+
23+
using Newtonsoft.Json;
24+
25+
Main();
26+
27+
void Main()
28+
{
29+
if (args is not [_, var jsonPath, ..])
30+
{
31+
Console.Error.WriteLine("Usage: app <json-file>");
32+
return;
33+
}
34+
35+
var json = File.ReadAllText(jsonPath);
36+
var data = JsonConvert.DeserializeObject<Data>(json);
37+
// ...
38+
}
39+
40+
record Data(string field1, int field2);
41+
```
42+
43+
This basically works by having the `dotnet` command line interpret the `#:` directives in source files, produce a C# project XML document in memory, and pass it off to MSBuild. The in-memory project is sometimes called a "virtual project".
44+
45+
## Miscellaneous files changes
46+
47+
There is a long-standing backlog item to enhance the experience of working with miscellaneous files ("loose files" not associated with any project). We think that as part of the "file-based program" work, we can enable the following in such files without substantial issues:
48+
- Syntax diagnostics.
49+
- Intellisense for the "default" set of references. e.g. those references which are included in the project created by `dotnet new console` with the current SDK.
50+
51+
### Heuristic
52+
The IDE considers a file to be a file-based program, if:
53+
- It has any `#:` directives which configure the file-based program project, or,
54+
- It has any top-level statements.
55+
Any of the above is met, and, the file is not included in an ordinary `.csproj` project (i.e. it is not part of any ordinary project's list of `Compile` items).
56+
57+
### Opt-out
58+
59+
We added an opt-out flag with option name `dotnet.projects.enableFileBasedPrograms`. If issues arise with the file-based program experience, then VS Code users should set the corresponding setting `"dotnet.projects.enableFileBasedPrograms": false` to revert back to the old miscellaneous files experience.

src/Features/Core/Portable/Workspace/MiscellaneousFileUtilities.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ internal static ProjectInfo CreateMiscellaneousProjectInfoForDocument(
4646
compilationOptions = GetCompilationOptionsWithScriptReferenceResolvers(services, compilationOptions, filePath);
4747
}
4848

49+
if (parseOptions != null && fileExtension != languageInformation.ScriptExtension)
50+
{
51+
// Any non-script misc file should not complain about usage of '#:' ignored directives.
52+
parseOptions = parseOptions.WithFeatures([.. parseOptions.Features, new("FileBasedProgram", "true")]);
53+
}
54+
4955
var projectId = ProjectId.CreateNewId(debugName: $"{workspace.GetType().Name} Files Project for {filePath}");
5056
var documentId = DocumentId.CreateNewId(projectId, debugName: filePath);
5157

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ public sealed class TelemetryReporterTests(ITestOutputHelper testOutputHelper)
1414
{
1515
private async Task<ITelemetryReporter> CreateReporterAsync()
1616
{
17-
var exportProvider = await LanguageServerTestComposition.CreateExportProviderAsync(
17+
var (exportProvider, _) = await LanguageServerTestComposition.CreateExportProviderAsync(
1818
LoggerFactory,
1919
includeDevKitComponents: true,
2020
MefCacheDirectory.Path,
21-
[],
22-
out var _,
23-
out var _);
21+
[]);
2422

2523
// VS Telemetry requires this environment variable to be set.
2624
Environment.SetEnvironmentVariable("CommonPropertyBagPath", Path.GetTempFileName());

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerHostTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ protected sealed class TestLspServer : ILspClient, IAsyncDisposable
4646

4747
internal static async Task<TestLspServer> CreateAsync(ClientCapabilities clientCapabilities, ILoggerFactory loggerFactory, string cacheDirectory, bool includeDevKitComponents = true, string[]? extensionPaths = null)
4848
{
49-
var exportProvider = await LanguageServerTestComposition.CreateExportProviderAsync(
50-
loggerFactory, includeDevKitComponents, cacheDirectory, extensionPaths, out var _, out var assemblyLoader);
49+
var (exportProvider, assemblyLoader) = await LanguageServerTestComposition.CreateExportProviderAsync(
50+
loggerFactory, includeDevKitComponents, cacheDirectory, extensionPaths);
5151
var testLspServer = new TestLspServer(exportProvider, loggerFactory, assemblyLoader);
5252
var initializeResponse = await testLspServer.ExecuteRequestAsync<InitializeParams, InitializeResult>(Methods.InitializeName, new InitializeParams { Capabilities = clientCapabilities }, CancellationToken.None);
5353
Assert.NotNull(initializeResponse?.Capabilities);

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,14 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests;
1010

1111
internal sealed class LanguageServerTestComposition
1212
{
13-
public static Task<ExportProvider> CreateExportProviderAsync(
13+
public static async Task<(ExportProvider exportProvider, IAssemblyLoader assemblyLoader)> CreateExportProviderAsync(
1414
ILoggerFactory loggerFactory,
1515
bool includeDevKitComponents,
1616
string cacheDirectory,
17-
string[]? extensionPaths,
18-
out ServerConfiguration serverConfiguration,
19-
out IAssemblyLoader assemblyLoader)
17+
string[]? extensionPaths)
2018
{
2119
var devKitDependencyPath = includeDevKitComponents ? TestPaths.GetDevKitExtensionPath() : null;
22-
serverConfiguration = new ServerConfiguration(LaunchDebugger: false,
20+
var serverConfiguration = new ServerConfiguration(LaunchDebugger: false,
2321
LogConfiguration: new LogConfiguration(LogLevel.Trace),
2422
StarredCompletionsPath: null,
2523
TelemetryLevel: null,
@@ -32,8 +30,10 @@ public static Task<ExportProvider> CreateExportProviderAsync(
3230
ServerPipeName: null,
3331
UseStdIo: false);
3432
var extensionManager = ExtensionAssemblyManager.Create(serverConfiguration, loggerFactory);
35-
assemblyLoader = new CustomExportAssemblyLoader(extensionManager, loggerFactory);
33+
var assemblyLoader = new CustomExportAssemblyLoader(extensionManager, loggerFactory);
3634

37-
return LanguageServerExportProviderBuilder.CreateExportProviderAsync(extensionManager, assemblyLoader, devKitDependencyPath, cacheDirectory, loggerFactory, CancellationToken.None);
35+
var exportProvider = await LanguageServerExportProviderBuilder.CreateExportProviderAsync(extensionManager, assemblyLoader, devKitDependencyPath, cacheDirectory, loggerFactory, CancellationToken.None);
36+
exportProvider.GetExportedValue<ServerConfigurationFactory>().InitializeConfiguration(serverConfiguration);
37+
return (exportProvider, assemblyLoader);
3838
}
3939
}

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/WorkspaceProjectFactoryServiceTests.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ public sealed class WorkspaceProjectFactoryServiceTests(ITestOutputHelper testOu
1818
public async Task CreateProjectAndBatch()
1919
{
2020
var loggerFactory = new LoggerFactory();
21-
using var exportProvider = await LanguageServerTestComposition.CreateExportProviderAsync(
22-
loggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, [], out var serverConfiguration, out var _);
21+
var (exportProvider, _) = await LanguageServerTestComposition.CreateExportProviderAsync(
22+
loggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, []);
23+
using var _ = exportProvider;
2324

24-
exportProvider.GetExportedValue<ServerConfigurationFactory>()
25-
.InitializeConfiguration(serverConfiguration);
2625
await exportProvider.GetExportedValue<ServiceBrokerFactory>().CreateAsync();
2726

2827
var workspaceFactory = exportProvider.GetExportedValue<LanguageServerWorkspaceFactory>();
@@ -48,7 +47,7 @@ public async Task CreateProjectAndBatch()
4847
await batch.ApplyAsync(CancellationToken.None);
4948

5049
// Verify it actually did something; we won't exclusively test each method since those are tested at lower layers
51-
var project = workspaceFactory.Workspace.CurrentSolution.Projects.Single();
50+
var project = workspaceFactory.HostWorkspace.CurrentSolution.Projects.Single();
5251

5352
var document = Assert.Single(project.Documents);
5453
Assert.Equal(sourceFilePath, document.FilePath);
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Collections.Immutable;
6+
using System.Security;
7+
using Microsoft.CodeAnalysis.Features.Workspaces;
8+
using Microsoft.CodeAnalysis.Host;
9+
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry;
10+
using Microsoft.CodeAnalysis.MetadataAsSource;
11+
using Microsoft.CodeAnalysis.MSBuild;
12+
using Microsoft.CodeAnalysis.Options;
13+
using Microsoft.CodeAnalysis.ProjectSystem;
14+
using Microsoft.CodeAnalysis.Shared.Extensions;
15+
using Microsoft.CodeAnalysis.Shared.TestHooks;
16+
using Microsoft.CodeAnalysis.Text;
17+
using Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
18+
using Microsoft.CommonLanguageServerProtocol.Framework;
19+
using Microsoft.Extensions.Logging;
20+
using Microsoft.VisualStudio.Composition;
21+
using Roslyn.LanguageServer.Protocol;
22+
using Roslyn.Utilities;
23+
using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager;
24+
25+
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
26+
27+
/// <summary>Handles loading both miscellaneous files and file-based program projects.</summary>
28+
internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoader, ILspMiscellaneousFilesWorkspaceProvider
29+
{
30+
private readonly ILspServices _lspServices;
31+
private readonly ILogger<FileBasedProgramsProjectSystem> _logger;
32+
private readonly IMetadataAsSourceFileService _metadataAsSourceFileService;
33+
34+
public FileBasedProgramsProjectSystem(
35+
ILspServices lspServices,
36+
IMetadataAsSourceFileService metadataAsSourceFileService,
37+
LanguageServerWorkspaceFactory workspaceFactory,
38+
IFileChangeWatcher fileChangeWatcher,
39+
IGlobalOptionService globalOptionService,
40+
ILoggerFactory loggerFactory,
41+
IAsynchronousOperationListenerProvider listenerProvider,
42+
ProjectLoadTelemetryReporter projectLoadTelemetry,
43+
ServerConfigurationFactory serverConfigurationFactory,
44+
BinlogNamer binlogNamer)
45+
: base(
46+
workspaceFactory.FileBasedProgramsProjectFactory,
47+
workspaceFactory.TargetFrameworkManager,
48+
workspaceFactory.ProjectSystemHostInfo,
49+
fileChangeWatcher,
50+
globalOptionService,
51+
loggerFactory,
52+
listenerProvider,
53+
projectLoadTelemetry,
54+
serverConfigurationFactory,
55+
binlogNamer)
56+
{
57+
_lspServices = lspServices;
58+
_logger = loggerFactory.CreateLogger<FileBasedProgramsProjectSystem>();
59+
_metadataAsSourceFileService = metadataAsSourceFileService;
60+
}
61+
62+
public Workspace Workspace => ProjectFactory.Workspace;
63+
64+
private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString;
65+
66+
public async ValueTask<TextDocument?> AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger)
67+
{
68+
var documentFilePath = GetDocumentFilePath(uri);
69+
70+
// https://github.com/dotnet/roslyn/issues/78421: MetadataAsSource should be its own workspace
71+
if (_metadataAsSourceFileService.TryAddDocumentToWorkspace(documentFilePath, documentText.Container, out var documentId))
72+
{
73+
var metadataWorkspace = _metadataAsSourceFileService.TryGetWorkspace();
74+
Contract.ThrowIfNull(metadataWorkspace);
75+
return metadataWorkspace.CurrentSolution.GetRequiredDocument(documentId);
76+
}
77+
78+
var primordialDoc = AddPrimordialDocument(uri, documentText, languageId);
79+
Contract.ThrowIfNull(primordialDoc.FilePath);
80+
81+
var doDesignTimeBuild = uri.ParsedUri?.IsFile is true
82+
&& primordialDoc.Project.Language == LanguageNames.CSharp
83+
&& GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms);
84+
await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild);
85+
86+
return primordialDoc;
87+
88+
TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, string languageId)
89+
{
90+
var languageInfoProvider = _lspServices.GetRequiredService<ILanguageInfoProvider>();
91+
if (!languageInfoProvider.TryGetLanguageInformation(uri, languageId, out var languageInformation))
92+
{
93+
Contract.Fail($"Could not find language information for {uri} with absolute path {documentFilePath}");
94+
}
95+
96+
var workspace = Workspace;
97+
var sourceTextLoader = new SourceTextLoader(documentText, documentFilePath);
98+
var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument(
99+
workspace, documentFilePath, sourceTextLoader, languageInformation, documentText.ChecksumAlgorithm, workspace.Services.SolutionServices, []);
100+
101+
ProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo));
102+
103+
// https://github.com/dotnet/roslyn/pull/78267
104+
// Work around an issue where opening a Razor file in the misc workspace causes a crash.
105+
if (languageInformation.LanguageName == LanguageInfoProvider.RazorLanguageName)
106+
{
107+
var docId = projectInfo.AdditionalDocuments.Single().Id;
108+
return workspace.CurrentSolution.GetRequiredAdditionalDocument(docId);
109+
}
110+
111+
var id = projectInfo.Documents.Single().Id;
112+
return workspace.CurrentSolution.GetRequiredDocument(id);
113+
}
114+
}
115+
116+
public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace)
117+
{
118+
var documentPath = GetDocumentFilePath(uri);
119+
if (removeFromMetadataWorkspace && _metadataAsSourceFileService.TryRemoveDocumentFromWorkspace(documentPath))
120+
{
121+
return;
122+
}
123+
124+
await UnloadProjectAsync(documentPath);
125+
}
126+
127+
protected override async Task<(RemoteProjectFile projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)?> TryLoadProjectInMSBuildHostAsync(
128+
BuildHostProcessManager buildHostProcessManager, string documentPath, CancellationToken cancellationToken)
129+
{
130+
const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore;
131+
var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken);
132+
133+
var loader = ProjectFactory.CreateFileTextLoader(documentPath);
134+
var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken);
135+
var (virtualProjectContent, isFileBasedProgram) = VirtualCSharpFileBasedProgramProject.MakeVirtualProjectContent(documentPath, textAndVersion.Text);
136+
137+
// 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.
138+
// This is necessary in order to get msbuild to apply the standard c# props/targets to the project.
139+
var virtualProjectPath = VirtualCSharpFileBasedProgramProject.GetVirtualProjectPath(documentPath);
140+
var loadedFile = await buildHost.LoadProjectAsync(virtualProjectPath, virtualProjectContent, languageName: LanguageNames.CSharp, cancellationToken);
141+
return (loadedFile, hasAllInformation: isFileBasedProgram, preferred: buildHostKind, actual: buildHostKind);
142+
}
143+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Composition;
6+
using Microsoft.CodeAnalysis.Host;
7+
using Microsoft.CodeAnalysis.Host.Mef;
8+
using Microsoft.CodeAnalysis.LanguageServer.Handler;
9+
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry;
10+
using Microsoft.CodeAnalysis.MetadataAsSource;
11+
using Microsoft.CodeAnalysis.Options;
12+
using Microsoft.CodeAnalysis.ProjectSystem;
13+
using Microsoft.CodeAnalysis.Shared.TestHooks;
14+
using Microsoft.CommonLanguageServerProtocol.Framework;
15+
using Microsoft.Extensions.Logging;
16+
17+
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
18+
19+
/// <summary>
20+
/// Service to create <see cref="LspMiscellaneousFilesWorkspaceProvider"/> instances.
21+
/// This is not exported as a <see cref="ILspServiceFactory"/> as it requires
22+
/// special base language server dependencies such as the <see cref="HostServices"/>
23+
/// </summary>
24+
[ExportCSharpVisualBasicStatelessLspService(typeof(ILspMiscellaneousFilesWorkspaceProviderFactory), WellKnownLspServerKinds.CSharpVisualBasicLspServer), Shared]
25+
[method: ImportingConstructor]
26+
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
27+
internal sealed class FileBasedProgramsWorkspaceProviderFactory(
28+
IMetadataAsSourceFileService metadataAsSourceFileService,
29+
LanguageServerWorkspaceFactory workspaceFactory,
30+
IFileChangeWatcher fileChangeWatcher,
31+
IGlobalOptionService globalOptionService,
32+
ILoggerFactory loggerFactory,
33+
IAsynchronousOperationListenerProvider listenerProvider,
34+
ProjectLoadTelemetryReporter projectLoadTelemetry,
35+
ServerConfigurationFactory serverConfigurationFactory,
36+
BinlogNamer binlogNamer) : ILspMiscellaneousFilesWorkspaceProviderFactory
37+
{
38+
public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices)
39+
{
40+
return new FileBasedProgramsProjectSystem(lspServices, metadataAsSourceFileService, workspaceFactory, fileChangeWatcher, globalOptionService, loggerFactory, listenerProvider, projectLoadTelemetry, serverConfigurationFactory, binlogNamer);
41+
}
42+
}

0 commit comments

Comments
 (0)