Skip to content

Commit 264c182

Browse files
committed
File-based programs IDE support
1 parent 32345c5 commit 264c182

20 files changed

+601
-119
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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
## High-level IDE layer changes
52+
53+
The following outline is for "single-file" scenarios. We are interested in expanding to multi-file as well, and this document will be expanded in the future with additional explanation of how to handle that scenario.
54+
55+
### Prerequisite work
56+
- MSBuildHost is updated to allow passing project content along with a file path. (https://github.com/dotnet/roslyn/pull/78303)
57+
- LanguageServerProjectSystem has a base type extracted so that a FileBasedProgramProjectSystem can be added. The latter handles loading file-based program projects, and makes the appropriate call to obtain the file-based program project XML. (https://github.com/dotnet/roslyn/pull/78329)
58+
59+
### Heuristic
60+
The IDE considers a file to be a file-based program, if:
61+
- It has any `#:` directives which configure the file-based program project, or,
62+
- It has any top-level statements.
63+
- 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).
64+
65+
### Opt-out
66+
67+
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/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/WorkspaceProjectFactoryServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public async Task CreateProjectAndBatch()
4848
await batch.ApplyAsync(CancellationToken.None);
4949

5050
// 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();
51+
var project = workspaceFactory.HostWorkspace.CurrentSolution.Projects.Single();
5252

5353
var document = Assert.Single(project.Documents);
5454
Assert.Equal(sourceFilePath, document.FilePath);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoader, ILspMiscellaneousFilesWorkspaceProvider
28+
{
29+
private readonly ILspServices _lspServices;
30+
private readonly ILogger<FileBasedProgramsProjectSystem> _logger;
31+
private readonly IMetadataAsSourceFileService _metadataAsSourceFileService;
32+
33+
public FileBasedProgramsProjectSystem(
34+
ILspServices lspServices,
35+
IMetadataAsSourceFileService metadataAsSourceFileService,
36+
LanguageServerWorkspaceFactory workspaceFactory,
37+
IFileChangeWatcher fileChangeWatcher,
38+
IGlobalOptionService globalOptionService,
39+
ILoggerFactory loggerFactory,
40+
IAsynchronousOperationListenerProvider listenerProvider,
41+
ProjectLoadTelemetryReporter projectLoadTelemetry,
42+
ServerConfigurationFactory serverConfigurationFactory,
43+
BinlogNamer binlogNamer)
44+
: base(
45+
workspaceFactory.FileBasedProgramsProjectFactory,
46+
workspaceFactory.TargetFrameworkManager,
47+
workspaceFactory.ProjectSystemHostInfo,
48+
fileChangeWatcher,
49+
globalOptionService,
50+
loggerFactory,
51+
listenerProvider,
52+
projectLoadTelemetry,
53+
serverConfigurationFactory,
54+
binlogNamer)
55+
{
56+
_lspServices = lspServices;
57+
_logger = loggerFactory.CreateLogger<FileBasedProgramsProjectSystem>();
58+
_metadataAsSourceFileService = metadataAsSourceFileService;
59+
}
60+
61+
public Workspace Workspace => ProjectFactory.Workspace;
62+
63+
public async Task<TextDocument?> AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger)
64+
{
65+
var (documentPath, isFile) = uri.ParsedUri is { } parsedUri
66+
? (ProtocolConversions.GetDocumentFilePathFromUri(parsedUri), isFile: parsedUri.IsFile)
67+
: (uri.UriString, isFile: false);
68+
69+
var container = documentText.Container;
70+
if (_metadataAsSourceFileService.TryAddDocumentToWorkspace(documentPath, container, out var documentId))
71+
{
72+
var metadataWorkspace = _metadataAsSourceFileService.TryGetWorkspace();
73+
Contract.ThrowIfNull(metadataWorkspace);
74+
var metadataDoc = metadataWorkspace.CurrentSolution.GetRequiredDocument(documentId);
75+
return metadataDoc;
76+
}
77+
78+
var languageInfoProvider = _lspServices.GetRequiredService<ILanguageInfoProvider>();
79+
if (!languageInfoProvider.TryGetLanguageInformation(uri, languageId, out var languageInformation))
80+
{
81+
// Only log here since throwing here could take down the LSP server.
82+
logger.LogError($"Could not find language information for {uri} with absolute path {documentPath}");
83+
return null;
84+
}
85+
86+
if (!isFile || !GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms))
87+
{
88+
// For now, we cannot provide intellisense etc on files which are not on disk or are not C#.
89+
var sourceTextLoader = new SourceTextLoader(documentText, documentPath);
90+
var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument(
91+
Workspace, documentPath, sourceTextLoader, languageInformation, documentText.ChecksumAlgorithm, Workspace.CurrentSolution.Services, []);
92+
await ProjectFactory.ApplyChangeToWorkspaceAsync(ws => ws.OnProjectAdded(projectInfo), cancellationToken: default);
93+
94+
var newSolution = Workspace.CurrentSolution;
95+
if (languageInformation.LanguageName == "Razor")
96+
{
97+
var docId = projectInfo.AdditionalDocuments.Single().Id;
98+
return newSolution.GetRequiredAdditionalDocument(docId);
99+
}
100+
101+
var id = projectInfo.Documents.Single().Id;
102+
return newSolution.GetRequiredDocument(id);
103+
}
104+
105+
// We have a file on disk. Light up the file-based program experience.
106+
// For Razor files we need to override the language name to C# as that's what code is generated
107+
var isRazor = languageInformation.LanguageName == "Razor";
108+
var languageName = isRazor ? LanguageNames.CSharp : languageInformation.LanguageName;
109+
var documentFileInfo = new DocumentFileInfo(documentPath, logicalPath: documentPath, isLinked: false, isGenerated: false, folders: default);
110+
var projectFileInfo = new ProjectFileInfo()
111+
{
112+
Language = languageName,
113+
FilePath = VirtualProject.GetVirtualProjectPath(documentPath),
114+
CommandLineArgs = ["/langversion:preview", "/features:FileBasedProgram=true"],
115+
Documents = isRazor ? [] : [documentFileInfo],
116+
AdditionalDocuments = isRazor ? [documentFileInfo] : [],
117+
AnalyzerConfigDocuments = [],
118+
ProjectReferences = [],
119+
PackageReferences = [],
120+
ProjectCapabilities = [],
121+
ContentFilePaths = [],
122+
FileGlobs = []
123+
};
124+
125+
var projectSet = AddLoadedProjectSet(documentPath);
126+
Project workspaceProject;
127+
using (await projectSet.Semaphore.DisposableWaitAsync())
128+
{
129+
var loadedProject = await this.CreateAndTrackInitialProjectAsync_NoLock(projectSet, documentPath, language: languageName);
130+
await loadedProject.UpdateWithNewProjectInfoAsync(projectFileInfo, hasAllInformation: false, _logger);
131+
132+
ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: true));
133+
loadedProject.NeedsReload += (_, _) => ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: false));
134+
workspaceProject = ProjectFactory.Workspace.CurrentSolution.GetRequiredProject(loadedProject.ProjectId);
135+
}
136+
137+
var document = isRazor ? workspaceProject.AdditionalDocuments.Single() : workspaceProject.Documents.Single();
138+
139+
_ = Task.Run(async () =>
140+
{
141+
await ProjectsToLoadAndReload.WaitUntilCurrentBatchCompletesAsync();
142+
await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync();
143+
});
144+
145+
Contract.ThrowIfFalse(document.FilePath == documentPath);
146+
return document;
147+
}
148+
149+
public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace)
150+
{
151+
var documentPath = uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString;
152+
await TryUnloadProjectSetAsync(documentPath);
153+
154+
// also do an unload in case this was the non-file scenario
155+
if (removeFromMetadataWorkspace && uri.ParsedUri is not null && _metadataAsSourceFileService.TryRemoveDocumentFromWorkspace(ProtocolConversions.GetDocumentFilePathFromUri(uri.ParsedUri)))
156+
{
157+
return;
158+
}
159+
160+
var matchingDocument = Workspace.CurrentSolution.GetDocumentIds(uri).SingleOrDefault();
161+
if (matchingDocument != null)
162+
{
163+
var project = Workspace.CurrentSolution.GetRequiredProject(matchingDocument.ProjectId);
164+
Workspace.OnProjectRemoved(project.Id);
165+
}
166+
}
167+
168+
protected override async Task<(RemoteProjectFile? projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)> TryLoadProjectAsync(
169+
BuildHostProcessManager buildHostProcessManager, string documentPath, CancellationToken cancellationToken)
170+
{
171+
const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore;
172+
var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken);
173+
Contract.ThrowIfFalse(Path.GetExtension(documentPath) == ".cs");
174+
175+
var fakeProjectPath = VirtualProject.GetVirtualProjectPath(documentPath);
176+
var loader = ProjectFactory.CreateFileTextLoader(documentPath);
177+
var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken: default);
178+
var (contentToLoad, isFileBasedProgram) = VirtualProject.MakeVirtualProjectContent(documentPath, textAndVersion.Text);
179+
180+
var loadedFile = await buildHost.LoadProjectAsync(fakeProjectPath, contentToLoad, languageName: LanguageNames.CSharp, cancellationToken);
181+
return (loadedFile, hasAllInformation: isFileBasedProgram, preferred: buildHostKind, actual: buildHostKind);
182+
}
183+
}
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)