diff --git a/docs/features/file-based-programs-vscode.md b/docs/features/file-based-programs-vscode.md index 5c0de78eff1f8..4769c34b0c1d3 100644 --- a/docs/features/file-based-programs-vscode.md +++ b/docs/features/file-based-programs-vscode.md @@ -156,6 +156,38 @@ This uses the file-based program entry point file, translates it to a virtual ms It uses file watchers to watch the project globs and redo the design time build on relevant changes, such as changes to `#:` directives. +## Automatic discovery + +The Roslyn LSP will automatically discover and load file-based apps in the opened workspace folders. The user can opt out of this discovery process by setting `"dotnet.fileBasedApps.enableAutomaticDiscovery": false`. +For the first release of the feature in the VS Code C# extension, the setting will be disabled-by-default in the stable release channel and enabled-by-default in the prerelease channel. + +Certain subfolders in a workspace are excluded from this discovery process: +- Any folders which contain a `.csproj` file. +- Any folders with names conventionally reserved for build artifacts, such as `artifacts`, `bin`, and `obj`. +- Any folders marked "hidden" in the file system. `.git` and `.vs` typically fall into this. + +The first time discovery is performed in a workspace, the LSP will read all `.cs` files in the opened workspace folders which are not excluded by the above conditions. If the file content starts with `#!`, it is marked as a file-based app and loaded. + +A cache file is created after each discovery pass and stored in the user temp directory. This file holds: +- The time that the previous discovery pass started. +- Paths of file-based apps found during the last discovery pass. +- Paths of folders that were found to contain `.csproj` files during the last discovery pass. + +The cache data allows the following optimizations in subsequent discovery passes: +- Allows not reading any C# files whose last write time is older than the cached time. +- Allows reducing the number of times we list files in directories whose last write time is older than the cached time. + +### `#!` requirement + +This design requires files to start with `#!` in order to participate in discovery. +Specifically, a discoverable file must start with either the byte sequence `0x23, 0x21` (ASCII/UTF-8 `#!`), or the byte sequence `0xEF, 0xBB, 0xBF, 0x23, 0x21` (UTF-8 BOM followed by `#!`). + +The reason for this is: we anticipate adding support for `#:` to non-entry-point files. This means that having `#:` is not going to be enough to identify a file as definitely the entry point. + +Instead, it will be necessary to search for both `#:` and top-level statements at a minimum. This cost is acceptable for files that were explicitly opened in the editor, but is a bit steep for a broad discovery pass. + +For this reason, we intend to put `#!`-at-start as a standard for entry points of file-based apps. We plan on shipping an analyzer which reports a warning in files which contain both `#:include` and top-level statements, but do not have `#!` at the top. + ## Future considerations This section is not intended to serve as permanent documentation but as more of a roadmap for a series of changes we may make in this area in the near future. It should not be necessary to read/understand this in order to evaluate a PR currently under review. i.e. anything that the current PR is actually implementing is covered in previous sections. @@ -168,21 +200,10 @@ We may want to make a change in the future, to stop using this designation for f ### Allowing non-entry-point files to contain `#:` directives -We are considering adding support for non-entry-point files to contain `#:` in the future. In this case, we would need an additional bit of information to distinguish entry points from non-entry-points. We think we want users to use a `#!` at the top of the file, in this case, to indicate that it is an entry point. +We are considering adding support for non-entry-point files to contain `#:` in the future. In this case, we would need an additional bit of information to distinguish entry points from non-entry-points. +For *multi-file file-based apps*, users should use a `#!` at the top of the entry point file to make it easy to identify. +For *single-file file-based apps*, we think that just using `#:` and top-level statements together should be enough, to identify a file that was explicitly opened in the editor as an entry point. ### Checking top-level statements presence without doing a full parse Currently there are cases where we may end up needing to do an additional parse of a file just to check if it contains top-level statements. This is generally a situation we'd like to avoid, and, would prefer to either use a pattern where the file already exists in some project and has a syntax tree we can check incrementally, or, that we devise some other solution for performing our heuristics which doesn't require a full parse. - -### Automatic discovery - -Currently, the main way file-based apps are discovered is: a classification is performed when a document is requested for a file which was not found in the host workspace. If the classification indicates the file is a file-based app entry point, then a load is initiated for it. - -In situations where the user opens an ordinary file `#:include`d by a file-based app, there is a desire to somehow discover the file-based app entry points which haven't been opened, in order to give full information about the file that was opened. - -We are considering various methods for accomplishing this, such as: -- performing a "crawl" of `.cs` files in the workspace which are outside any `.csproj` cone, and: - - cracking the `.cs` files to check for `#:` or `#!` directives, or, possibly requiring a naming convention such as `MyTool.app.cs` -- or introducing some convention for listing the paths of file-based apps in a discoverable location. (smells very strongly like a solution file). - -It feels like "low-configuration, low-ceremony, simple conventions", is the norm for file-based apps. So, it feels like doing a crawl which includes some heuristics to ignore files that are very likely not file-based app entry points, may be viable here. We just need to do the work and prove it out. diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsEntryPointDiscoveryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsEntryPointDiscoveryTests.cs new file mode 100644 index 0000000000000..d411c6d3e1b53 --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsEntryPointDiscoveryTests.cs @@ -0,0 +1,1011 @@ +// 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.Runtime.CompilerServices; +using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +using Microsoft.CodeAnalysis.LanguageServer.UnitTests.Miscellaneous; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.Shared.Extensions; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Shared.Utilities; +using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.UnitTests; +using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; +using Microsoft.CommonLanguageServerProtocol.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Composition; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Test.Utilities; +using Roslyn.Utilities; +using StreamJsonRpc; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; + +public sealed class FileBasedProgramsEntryPointDiscoveryTests : AbstractLanguageServerProtocolTests, IDisposable +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly ILoggerFactory _loggerFactory; + private readonly TestOutputLoggerProvider _loggerProvider; + private readonly TempRoot _tempRoot; + private readonly TempDirectory _mefCacheDirectory; + + private readonly List _additionalDirectoriesToDelete = []; + + public FileBasedProgramsEntryPointDiscoveryTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _loggerProvider = new TestOutputLoggerProvider(testOutputHelper); + _loggerFactory = new LoggerFactory([_loggerProvider]); + _tempRoot = new(); + _mefCacheDirectory = _tempRoot.CreateDirectory(); + } + + protected override async ValueTask CreateExportProviderAsync() + { + AsynchronousOperationListenerProvider.Enable(enable: true); + + var (exportProvider, _) = await LanguageServerTestComposition.CreateExportProviderAsync( + _loggerFactory, + includeDevKitComponents: false, + cacheDirectory: _mefCacheDirectory.Path, + extensionPaths: []); + + return exportProvider; + } + + public void Dispose() + { + _tempRoot.Dispose(); + _loggerProvider.Dispose(); + _loggerFactory.Dispose(); + + foreach (var directory in _additionalDirectoriesToDelete) + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + private void DeferDeleteCacheDirectory(string workspacePath) + { + _additionalDirectoriesToDelete.Add(VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(workspacePath)); + } + + /// Verify that multiple invocations of 'actualFactory' result in the same 'expected' sequence. + private void AssertSequenceEqualAndStable(IEnumerable expected, Func> actualFactory) + { + AssertEx.SequenceEqual(expected, actualFactory()); + AssertEx.SequenceEqual(expected, actualFactory()); + } + + [Fact] + public async Task TestDiscovery_Simple() + { + // Simple case + // tempDir/ + // App.cs + // Ordinary.cs + + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var appFile = tempDir.CreateFile("App.cs").WriteAllText(appText); + // Note: having '#:' is not enough for discovery to detect a file. The file needs to start with '#!'. + var ordinaryText = """ + #:sdk Microsoft.Net.Sdk + public class Ordinary { } + """; + var ordinaryFile = tempDir.CreateFile("Ordinary.cs").WriteAllText(ordinaryText); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + AssertSequenceEqualAndStable([appFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); + + // Changed but still has '#!' + appFile.WriteAllText(appText + """ + + Console.WriteLine("Additional content"); + """); + AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); + + // Deleted from disk + File.Delete(appFile.Path); + AssertEx.Empty(discovery.FindEntryPoints(tempDir.Path)); + + // Put back on disk + appFile.WriteAllText(appText); + AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); + + // Changed and no longer has '#!' + appFile.WriteAllText(""" + Console.WriteLine("No more #! at start of file"); + """); + AssertEx.Empty(discovery.FindEntryPoints(tempDir.Path)); + + // Changed and again has '#!' + appFile.WriteAllText(appText); + AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); + } + + [Fact] + public async Task TestDiscovery_IgnoredFolders() + { + // Demonstrate ignored folders behavior + // tempDir/ + // artifacts/App1.cs + // App2.cs + + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var artifactsDir = tempDir.CreateDirectory("artifacts"); + var app1Text = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var app1File = artifactsDir.CreateFile("App1.cs").WriteAllText(app1Text); + + var app2Text = app1Text; + var app2File = tempDir.CreateFile("App2.cs").WriteAllText(app2Text); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + AssertSequenceEqualAndStable([app2File.Path], () => discovery.FindEntryPoints(tempDir.Path)); + } + + [Fact] + public async Task TestDiscovery_CsprojInCone() + { + // Demonstrate csproj-in-cone behavior + // tempDir/ + // Project/ + // Project.csproj + // Program.cs + // App.cs + + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var projectDir = tempDir.CreateDirectory("Project"); + var csprojFile = projectDir.CreateFile("Project.csproj"); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var programFile = projectDir.CreateFile("Program.cs").WriteAllText(appText); + var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + AssertSequenceEqualAndStable([appFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); + + // Delete the csproj file + File.Delete(csprojFile.Path); + AssertSequenceEqualAndStable([appFile.Path, programFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); + } + + [Fact] + public async Task TestDiscovery_Option_EnableFileBasedPrograms_True() + { + // Ensure discovery occurs when relevant options are enabled + // Note: the option is checked in the higher level API, so we need to verify the effects in project system. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + OptionUpdater = options => options.SetGlobalOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms, true), + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + await discovery.FindAndLoadEntryPointsAsync(); + await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); + var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); + Assert.Equal(WorkspaceKind.Host, workspace.Kind); + Assert.NotNull(document); + } + + [Fact] + public async Task TestDiscovery_Option_EnableFileBasedPrograms_False() + { + // Ensure discovery doesn't occur when 'dotnet.projects.enableFileBasedPrograms: false' is set + // Note: the option is checked in the higher level API, so we need to verify the effects in project system. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + OptionUpdater = options => options.SetGlobalOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms, false), + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + await discovery.FindAndLoadEntryPointsAsync(); + await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); + var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); + Assert.Null(workspace); + Assert.Null(document); + } + + [Fact] + public async Task TestDiscovery_Option_EnableAutomaticDiscovery_False() + { + // Ensure discovery doesn't occur when 'dotnet.fileBasedApps.enableAutomaticDiscovery: false' is set + // Note: the option is checked in the higher level API, so we need to verify the effects in project system. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + """; + var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + OptionUpdater = options => options.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, false), + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + await discovery.FindAndLoadEntryPointsAsync(); + await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); + var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); + Assert.Null(workspace); + Assert.Null(document); + } + + [Fact] + public async Task TestDiscovery_UTF8_BOM() + { + // File starting with UTF-8 BOM followed by '#!' should be discovered + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + var appText = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("Hello World"); + + """; + var bomAppText = "\uFEFF" + appText; + var appFile = tempDir.CreateFile("App.cs").WriteAllText(bomAppText); + var ordinaryFile = tempDir.CreateFile("Ordinary.cs").WriteAllText("public class Ordinary { }"); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + + var discovery = testLspServer.GetRequiredLspService(); + AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); + } + + private static async Task<(Workspace? workspace, Document? document)> GetLspWorkspaceAndDocumentAsync(DocumentUri uri, TestLspServer testLspServer) + { + var (workspace, _, document) = await testLspServer.GetManager().GetLspDocumentInfoAsync(CreateTextDocumentIdentifier(uri), CancellationToken.None).ConfigureAwait(false); + return (workspace, document as Document); + } + + private static async Task<(Workspace workspace, Document document)> GetRequiredLspWorkspaceAndDocumentAsync(DocumentUri uri, TestLspServer testLspServer) + { + var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(uri, testLspServer); + Assert.NotNull(workspace); + Assert.NotNull(document); + return (workspace, document); + } + + [Fact] + public async Task Swap_ReplaceFBAWithNonFBA() + { + // Swap an FBA out for non-FBA at the same path 'sub1/File1.cs'. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File2.cs"), OrdinaryCsContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + File.Move(Path.Combine(tempDir.Path, @"sub1/File1.cs"), Path.Combine(tempDir.Path, @"sub1/File4.cs")); + File.Move(Path.Combine(tempDir.Path, @"sub1/File2.cs"), Path.Combine(tempDir.Path, @"sub1/File1.cs")); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache - should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Swap_ReplaceNonFBAWithFBA() + { + // Swap a non-FBA out for FBA at the same path 'sub/File1.cs'. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File2.cs"), FbaContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + File.Move(Path.Combine(tempDir.Path, @"sub1/File1.cs"), Path.Combine(tempDir.Path, @"sub1/File4.cs")); + File.Move(Path.Combine(tempDir.Path, @"sub1/File2.cs"), Path.Combine(tempDir.Path, @"sub1/File1.cs")); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Swap_ReplaceFBADirectoryWithNonFBADirectory() + { + // Swap a directory containing FBA out for a directory containing non-FBA at 'sub1/File1.cs'. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub2")); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub2/File1.cs"), OrdinaryCsContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + Directory.Move(Path.Combine(tempDir.Path, @"sub1"), Path.Combine(tempDir.Path, @"sub4")); + Directory.Move(Path.Combine(tempDir.Path, @"sub2"), Path.Combine(tempDir.Path, @"sub1")); + + // Discovery with cache - should match + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Swap_ReplaceNonFBADirectoryWithFBADirectory() + { + // Swap a directory containing non-FBA out for a directory containing FBA at the same path 'sub1/File1.cs'. + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub2")); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub2/File1.cs"), FbaContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + Directory.Move(Path.Combine(tempDir.Path, @"sub1"), Path.Combine(tempDir.Path, @"sub4")); + Directory.Move(Path.Combine(tempDir.Path, @"sub2"), Path.Combine(tempDir.Path, @"sub1")); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Fuzz_1() + { + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + File.WriteAllText(Path.Combine(tempDir.Path, @"Fba0.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"Fba1.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), OrdinaryCsContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + File.WriteAllText(Path.Combine(tempDir.Path, @"New102.csproj"), CsprojContent); + File.Delete(Path.Combine(tempDir.Path, @"Fba0.cs")); + File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd22.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd5.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"New79.csproj"), CsprojContent); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Fuzz_2() + { + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + File.WriteAllText(Path.Combine(tempDir.Path, @"Fba0.cs"), FbaContent); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested")); + File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Fba1.cs"), FbaContent); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested")); + File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Project2.csproj"), CsprojContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"Project3.csproj"), CsprojContent); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested/sub3")); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd40.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/sub3/New52.csproj"), CsprojContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/NewOrd20.cs"), OrdinaryCsContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Fba1.cs"), OrdinaryCsContent); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task Fuzz_3() + { + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + // Setup + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); + Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1/sub3")); + File.WriteAllText(Path.Combine(tempDir.Path, @"Project0.csproj"), CsprojContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/sub3/Fba1.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Fba2.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Fba3.cs"), FbaContent); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Ordinary4.cs"), OrdinaryCsContent); + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + File.Delete(Path.Combine(tempDir.Path, @"Project0.csproj")); + File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/sub3/NewFba64.cs"), FbaContent); + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); + } + + #region Fuzzer + + private const string FbaContent = """ + #!/usr/bin/env dotnet + #:sdk Microsoft.Net.SDK + Console.WriteLine("hello"); + + """; + private const string OrdinaryCsContent = """ + public class C {} + + """; + private const string CsprojContent = ""; + + /// + /// Describes a single filesystem operation performed during a fuzz iteration. + /// + private abstract record FuzzOp + { + protected static string NormalizeForCSharp(string relativePath) => relativePath.Replace('\\', '/'); + + public abstract string ToCSharp(string tempDirVar); + + /// Creates a directory at the given relative path. + internal sealed record CreateDir(string RelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"Directory.CreateDirectory(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"));"; + } + + /// Writes a .cs file with file-based-app content (starts with '#!'). + internal sealed record WriteFbaFile(string RelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), FbaContent);"; + } + + /// Writes a .cs file without file-based-app content (no '#!' at start). + internal sealed record WriteOrdinaryCs(string RelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), OrdinaryCsContent);"; + } + + /// Writes a .csproj file. + internal sealed record WriteCsproj(string RelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), CsprojContent);"; + } + + /// Deletes a file. + internal sealed record DeleteFile(string RelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"File.Delete(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"));"; + } + + /// Renames/moves a file. + internal sealed record RenameFile(string OldRelativePath, string NewRelativePath) : FuzzOp + { + public override string ToCSharp(string tempDirVar) => $"File.Move(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(OldRelativePath)}\"), Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(NewRelativePath)}\"));"; + } + } + + /// + /// Tracks what files exist in the virtual workspace to enable the fuzzer + /// to generate valid operations (e.g. only delete files that exist). + /// + private sealed class FuzzWorkspace + { + private readonly string _rootPath; + private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _files = new(StringComparer.OrdinalIgnoreCase); + + public FuzzWorkspace(string rootPath) + { + _rootPath = rootPath; + _directories.Add(""); // root + } + + public IReadOnlyCollection Directories => _directories; + public IReadOnlyCollection Files => _files; + + public string FullPath(string relativePath) => Path.Combine(_rootPath, relativePath); + + public void Apply(FuzzOp op) + { + switch (op) + { + case FuzzOp.CreateDir createDir: + _directories.Add(createDir.RelativePath); + Directory.CreateDirectory(FullPath(createDir.RelativePath)); + break; + case FuzzOp.WriteFbaFile writeFba: + _files.Add(writeFba.RelativePath); + File.WriteAllText(FullPath(writeFba.RelativePath), FbaContent); + break; + case FuzzOp.WriteOrdinaryCs writeCs: + _files.Add(writeCs.RelativePath); + File.WriteAllText(FullPath(writeCs.RelativePath), OrdinaryCsContent); + break; + case FuzzOp.WriteCsproj writeCsproj: + _files.Add(writeCsproj.RelativePath); + File.WriteAllText(FullPath(writeCsproj.RelativePath), CsprojContent); + break; + case FuzzOp.DeleteFile deleteFile: + _files.Remove(deleteFile.RelativePath); + File.Delete(FullPath(deleteFile.RelativePath)); + break; + case FuzzOp.RenameFile rename: + _files.Remove(rename.OldRelativePath); + _files.Add(rename.NewRelativePath); + File.Move(FullPath(rename.OldRelativePath), FullPath(rename.NewRelativePath)); + break; + } + } + } + + private static readonly string[] s_dirNames = ["sub1", "sub2", "sub3", "deep" + Path.DirectorySeparatorChar + "nested"]; + + /// + /// Generates a random "setup" operation (creating directories and files). + /// + private static FuzzOp GenerateSetupOp(Random random, FuzzWorkspace workspace) + { + // Weighted: create dirs early, then files + var dirList = workspace.Directories.ToArray(); + if (dirList.Length < 4 && random.Next(3) == 0) + { + // Create a subdirectory + var parentDir = dirList[random.Next(dirList.Length)]; + var name = s_dirNames[random.Next(s_dirNames.Length)]; + var relativePath = parentDir.Length == 0 ? name : Path.Combine(parentDir, name); + return new FuzzOp.CreateDir(relativePath); + } + + // Create a file in a random directory + var dir = dirList[random.Next(dirList.Length)]; + var fileIndex = workspace.Files.Count; + return random.Next(4) switch + { + 0 => new FuzzOp.WriteFbaFile(Path.Combine(dir, $"Fba{fileIndex}.cs")), + 1 => new FuzzOp.WriteOrdinaryCs(Path.Combine(dir, $"Ordinary{fileIndex}.cs")), + 2 => new FuzzOp.WriteCsproj(Path.Combine(dir, $"Project{fileIndex}.csproj")), + _ => new FuzzOp.WriteFbaFile(Path.Combine(dir, $"Fba{fileIndex}.cs")), + }; + } + + /// + /// Generates a random "edit" operation (modifying, deleting, renaming files, or creating/deleting csproj). + /// + private static FuzzOp? GenerateEditOp(Random random, FuzzWorkspace workspace) + { + var files = workspace.Files.ToArray(); + if (files.Length == 0) + return null; + + var dirList = workspace.Directories.ToArray(); + var choice = random.Next(7); + + if (choice == 0) + return new FuzzOp.DeleteFile(files[random.Next(files.Length)]); + + if (choice == 1) + { + var oldPath = files[random.Next(files.Length)]; + var dir = dirList[random.Next(dirList.Length)]; + var newPath = Path.Combine(dir, "moved_" + Path.GetFileName(oldPath)); + if (workspace.Files.Contains(newPath)) + return null; + return new FuzzOp.RenameFile(oldPath, newPath); + } + + if (choice == 2) + return new FuzzOp.WriteFbaFile(Path.Combine(dirList[random.Next(dirList.Length)], $"NewFba{workspace.Files.Count + random.Next(100)}.cs")); + + if (choice == 3) + return new FuzzOp.WriteOrdinaryCs(Path.Combine(dirList[random.Next(dirList.Length)], $"NewOrd{workspace.Files.Count + random.Next(100)}.cs")); + + if (choice == 4) + return new FuzzOp.WriteCsproj(Path.Combine(dirList[random.Next(dirList.Length)], $"New{workspace.Files.Count + random.Next(100)}.csproj")); + + var csFiles = files.Where(f => f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (csFiles.Length == 0) + return null; + + if (choice == 5) + return new FuzzOp.WriteFbaFile(csFiles[random.Next(csFiles.Length)]); + + if (choice == 6) + return new FuzzOp.WriteOrdinaryCs(csFiles[random.Next(csFiles.Length)]); + + throw ExceptionUtilities.UnexpectedValue(choice); + } + + [Fact] + public async Task Fuzz() + { + // Explicitly seed the random so that if we need to manually edit and repro the fuzzing process locally, the logs will help us to do that + var seed = Random.Shared.Next(); + var random = new Random(seed); + _testOutputHelper.WriteLine($"Random seed: {seed}"); + + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + + for (var iteration = 0; iteration < 1000; iteration++) + { + var workspace = new FuzzWorkspace(tempDir.Path); + var setupOps = new List(); + var editOps = new List(); + + try + { + // Clean workspace for each iteration + foreach (var entry in Directory.EnumerateFileSystemEntries(tempDir.Path)) + { + if (File.Exists(entry)) + File.Delete(entry); + else if (Directory.Exists(entry)) + Directory.Delete(entry, recursive: true); + } + + // Delete cache from any prior iteration + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + if (Directory.Exists(cacheDirectory)) + Directory.Delete(cacheDirectory, recursive: true); + + // Step 1: Generate random initial filesystem + var setupCount = random.Next(3, 12); + for (var i = 0; i < setupCount; i++) + { + var op = GenerateSetupOp(random, workspace); + setupOps.Add(op); + workspace.Apply(op); + } + + // Step 2: Discover entry points without cache + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Step 3: Random edits + var editCount = random.Next(1, 8); + for (var i = 0; i < editCount; i++) + { + var op = GenerateEditOp(random, workspace); + if (op != null) + { + editOps.Add(op); + workspace.Apply(op); + } + } + + // Step 4: Discover entry points using cache (cache was written by step 2) + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Step 5: Delete the cache + if (Directory.Exists(cacheDirectory)) + Directory.Delete(cacheDirectory, recursive: true); + + // Step 6: Discover without cache — should match step 4 + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase, + $"Iteration {iteration}: Cached result differs from uncached result."); + } + catch (Exception ex) when (IOUtilities.IsNormalIOException(ex)) + { + // Directories can randomly fail to delete etc when we are thrashing the disk. + // Not a big deal and not a reason to fail the test, just move on to the next iteration instead. + _testOutputHelper.WriteLine($"IO exception during fuzz testing: {ex.Message}"); + } + catch (Exception) + { + // Dump reproducible test case + DumpFuzzReproCase(iteration, setupOps, editOps); + throw; + } + } + } + + private void DumpFuzzReproCase(int iteration, List setupOps, List editOps) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($$""" + + [Fact] + public async Task Fuzz_{{iteration}}() + { + var tempDir = _tempRoot.CreateDirectory(); + DeferDeleteCacheDirectory(tempDir.Path); + sb.AppendLine(); + + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + WorkspaceFolders = + [ + new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = \"workspace1\" } + ] + }); + var discovery = testLspServer.GetRequiredLspService(); + sb.AppendLine(); + + // Setup + """); + foreach (var op in setupOps) + sb.AppendLine($" {op.ToCSharp("tempDir")}"); + + sb.AppendLine(""" + + // First discovery (no cache) + var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); + + // Edits + """); + foreach (var op in editOps) + sb.AppendLine($" {op.ToCSharp("tempDir")}"); + + sb.AppendLine(""" + + // Discovery with cache + var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Delete cache + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(tempDir.Path); + Directory.Delete(cacheDirectory, recursive: true); + + // Discovery without cache — should match + var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); + } + """); + + _testOutputHelper.WriteLine(sb.ToString()); + } + + #endregion +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs index b11c673d18fe5..bb4df08914574 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs @@ -208,6 +208,68 @@ public async Task TestFileBasedProgram_EntryPointClosed(bool mutatingLspWorkspac Assert.Null(document); } + [Theory, CombinatorialData] + public async Task TestFileBasedProgram_EntryPointClosed_RemainsLoadedWhenDiscoveryEnabled(bool mutatingLspWorkspace) + { + // When automatic discovery is enabled, closing the entry point file should not unload the project. + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer }); + + Assert.Null(await GetMiscellaneousDocumentAsync(testLspServer)); + var tempDir = _tempRoot.CreateDirectory(); + var sourceText = """ + #:sdk Microsoft.Net.Sdk + Console.WriteLine("Hello World!"); + """; + var sourceFile = tempDir.CreateFile("SomeFile.cs").WriteAllText(sourceText); + var looseFileUri = ProtocolConversions.CreateAbsoluteDocumentUri(sourceFile.Path); + await testLspServer.OpenDocumentAsync(looseFileUri, sourceText).ConfigureAwait(false); + await WaitForProjectLoad(looseFileUri, testLspServer); + + var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUri, testLspServer).ConfigureAwait(false); + Assert.Equal(WorkspaceKind.Host, workspace.Kind); + Assert.True(document.Project.State.HasAllInformation); + + await testLspServer.CloseDocumentAsync(looseFileUri); + + // Project remains loaded because automatic discovery is enabled (default). + (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUri, testLspServer).ConfigureAwait(false); + Assert.Equal(WorkspaceKind.Host, workspace.Kind); + Assert.True(document.Project.State.HasAllInformation); + } + + [Theory, CombinatorialData] + public async Task TestFileBasedProgram_EntryPointClosed_UnloadedWhenDiscoveryDisabled(bool mutatingLspWorkspace) + { + // When automatic discovery is disabled, closing the entry point file should unload the project. + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace, new InitializationOptions + { + ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, + OptionUpdater = options => options.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, false), + }); + + Assert.Null(await GetMiscellaneousDocumentAsync(testLspServer)); + var tempDir = _tempRoot.CreateDirectory(); + var sourceText = """ + #:sdk Microsoft.Net.Sdk + Console.WriteLine("Hello World!"); + """; + var sourceFile = tempDir.CreateFile("SomeFile.cs").WriteAllText(sourceText); + var looseFileUri = ProtocolConversions.CreateAbsoluteDocumentUri(sourceFile.Path); + await testLspServer.OpenDocumentAsync(looseFileUri, sourceText).ConfigureAwait(false); + await WaitForProjectLoad(looseFileUri, testLspServer); + + var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUri, testLspServer).ConfigureAwait(false); + Assert.Equal(WorkspaceKind.Host, workspace.Kind); + Assert.True(document.Project.State.HasAllInformation); + + await testLspServer.CloseDocumentAsync(looseFileUri); + + // Project is unloaded because automatic discovery is disabled. + (workspace, document) = await GetLspWorkspaceAndDocumentAsync(looseFileUri, testLspServer).ConfigureAwait(false); + Assert.Null(workspace); + Assert.Null(document); + } + [Theory, CombinatorialData] public async Task TestLooseFilesInCanonicalProject(bool mutatingLspWorkspace) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs index 985aa50e98bdd..ca8a7aad7ae3f 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs @@ -31,25 +31,8 @@ internal sealed class CsprojInConeChecker : ILspService, IOnInitialized public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken) { var initializeManager = context.GetRequiredService(); - var initializeParams = initializeManager.TryGetInitializeParams(); - Contract.ThrowIfNull(initializeParams); - _workspaceFolders = initializeParams.WorkspaceFolders is [_, ..] workspaceFolders ? GetFolderPaths(workspaceFolders) : []; + _workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths(); return Task.CompletedTask; - - static ImmutableArray GetFolderPaths(WorkspaceFolder[] workspaceFolders) - { - var builder = ArrayBuilder.GetInstance(workspaceFolders.Length); - foreach (var workspaceFolder in workspaceFolders) - { - if (workspaceFolder.DocumentUri.ParsedUri is not { } parsedUri) - continue; - - var workspaceFolderPath = ProtocolConversions.GetDocumentFilePathFromUri(parsedUri); - builder.Add(workspaceFolderPath); - } - - return builder.ToImmutableAndFree(); - } } public bool IsContainedInCsprojCone(string csFilePath) @@ -80,4 +63,4 @@ public bool IsContainedInCsprojCone(string csFilePath) return false; } -} \ No newline at end of file +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs new file mode 100644 index 0000000000000..41a1154b1525e --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs @@ -0,0 +1,365 @@ +// 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.Buffers; +using System.Collections.Immutable; +using System.Composition; +using System.Diagnostics; +using System.IO.Enumeration; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis.Collections; +using Microsoft.CodeAnalysis.ErrorReporting; +using Microsoft.CodeAnalysis.Features.Workspaces; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.PooledObjects; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Shared.Utilities; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Logging; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; + +[Shared] +[ExportLspServiceFactory(typeof(FileBasedProgramsEntryPointDiscovery), ProtocolConstants.RoslynLspLanguagesContract)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class FileBasedProgramsEntryPointDiscoveryFactory(IGlobalOptionService globalOptionService, IAsynchronousOperationListenerProvider listenerProvider, ILoggerFactory loggerFactory) : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + return new FileBasedProgramsEntryPointDiscovery(globalOptionService, listenerProvider.GetListener(FeatureAttribute.Workspace), loggerFactory, lspServices); + } +} + +internal sealed partial class FileBasedProgramsEntryPointDiscovery( + IGlobalOptionService globalOptionService, IAsynchronousOperationListener listener, ILoggerFactory loggerFactory, LspServices lspServices) : ILspService, IOnInitialized +{ + private static readonly StringComparer s_pathComparer = StringComparer.OrdinalIgnoreCase; + + /// Directories which are ignored per convention. + /// Some conventional directories like '.git' and '.vs' are expected to be marked hidden and will be automatically ignored by discovery. + private static readonly SearchValues s_ignoredDirectories = SearchValues.Create([ + "artifacts", + "bin", + "obj", + "node_modules" + ], StringComparison.OrdinalIgnoreCase); + + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private ImmutableArray _workspaceFolders; + + public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken) + { + var initializeManager = context.GetRequiredService(); + _workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths(); + Task.Run(async () => + { + try + { + using var token = listener.BeginAsyncOperation(nameof(FindAndLoadEntryPointsAsync)); + await FindAndLoadEntryPointsAsync(); + } + catch (Exception ex) when (FatalError.ReportAndCatch(ex)) + { + throw ExceptionUtilities.Unreachable(); + } + }, cancellationToken); + + return Task.CompletedTask; + } + + internal async Task FindAndLoadEntryPointsAsync() + { + Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(FindAndLoadEntryPointsAsync)}."); + + if (_workspaceFolders.IsEmpty) + { + _logger.LogTrace("No workspace folders to search for file-based apps."); + return; + } + + if (!globalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms)) + { + _logger.LogTrace(@"""dotnet.projects.enableFileBasedPrograms"" is false. Not discovering entry points."); + return; + } + + if (!globalOptionService.GetOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery)) + { + _logger.LogTrace(@"""dotnet.fileBasedApps.enableAutomaticDiscovery"" is false. Not discovering entry points."); + return; + } + + var fileBasedProgramsProjectSystem = (FileBasedProgramsProjectSystem?)lspServices.GetService(); + Contract.ThrowIfNull(fileBasedProgramsProjectSystem); + + // Note: the overwhelmingly common case is when there is just one workspace folder. + // For simplicity we orient our search around one workspace folder at a time. + foreach (var workspaceFolder in _workspaceFolders) + { + foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder)) + { + await fileBasedProgramsProjectSystem.TryBeginLoadingFileBasedAppAsync(fileBasedAppPath); + } + } + + // Discovery pass done. Find and delete old caches. + IOUtilities.PerformIO(() => + { + using var enumerator = new OldCacheEnumerator(); + while (enumerator.MoveNext()) + { + IOUtilities.PerformIO(() => Directory.Delete(enumerator.Current, recursive: true)); + } + }); + } + + private sealed class OldCacheEnumerator() : FileSystemEnumerator( + directory: VirtualProjectXmlProvider.GetDiscoveryCacheRootDirectory(), + options: new() { RecurseSubdirectories = false }) + { + // Yield cache directories that have not been modified in 30 days (indicates they are stale and should be deleted) + private readonly DateTimeOffset _includeCachesEarlierThanUtc = DateTimeOffset.UtcNow - TimeSpan.FromDays(30); + + protected override string TransformEntry(ref FileSystemEntry entry) => entry.ToFullPath(); + + protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) + { + return entry.IsDirectory && entry.LastWriteTimeUtc < _includeCachesEarlierThanUtc; + } + } + + internal ImmutableArray FindEntryPoints(string workspaceFolder) + { + var stopwatch = SharedStopwatch.StartNew(); + var cacheDirectory = VirtualProjectXmlProvider.GetDiscoveryCacheDirectory(workspaceFolder); + var cacheFilePath = Path.Join(cacheDirectory, "cache.json"); + Cache? cache = null; + try + { + if (File.Exists(cacheFilePath)) + { + using var cacheFile = File.OpenRead(cacheFilePath); + cache = JsonSerializer.Deserialize(cacheFile, CacheSerializerContext.Default.Cache); + } + + // Drop malformed caches + if (cache != null + && (!cache.WorkspacePath.Equals(workspaceFolder, StringComparison.OrdinalIgnoreCase) + || cache.FileBasedAppFullPaths.IsDefault + || cache.DirectoriesContainingCsproj.IsDefault)) + { + cache = null; + } + } + catch (Exception ex) + { + _logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message); + } + + cache ??= new Cache(workspaceFolder, DateTimeOffset.MinValue, FileBasedAppFullPaths: [], DirectoriesContainingCsproj: []); + + // Note: file system timestamps can have a coarser resolution than DateTimeOffset. + // This means that using APIs like `DateTimeOffset.UtcNow`, then writing a file, then sampling its LastWriteTimeUtc, + // can result in the UtcNow that we accessed earlier, having a "later" value than the LastWriteTimeUtc. + // + // To deal with this accurately, we write a timestamp file to the filesystem, then get a walkStartTimeUtc from its LastWriteTimeUtc. + // We assume that file writes in the workspace folder which occur after this write, will have equal or later timestamps. + // Timestamps we encounter which compare equal to the walkStartTimeUtc timestamp, must be treated as possibly being newer than the walkStartTimeUtc timestamp. + var walkStartTimeUtc = IOUtilities.PerformIO(() => + { + var sentinelPath = Path.Join(cacheDirectory, $".walk-timestamp-{Guid.NewGuid()}"); + File.WriteAllBytes(sentinelPath, []); + var lastWriteTime = File.GetLastWriteTimeUtc(sentinelPath); + File.Delete(sentinelPath); + return lastWriteTime; + }, defaultValue: cache.LastWalkTimeUtc); + + var newFileBasedAppsBuilder = ArrayBuilder.GetInstance(cache.FileBasedAppFullPaths.Length); + var directoriesContainingCsprojBuilder = ArrayBuilder.GetInstance(cache.DirectoriesContainingCsproj.Length); + var visitor = new WorkspaceFolderVisitor(cache, newFileBasedAppsBuilder, directoriesContainingCsprojBuilder, _logger); + visitor.Visit(); + var elapsedMilliseconds = Math.Round(stopwatch.Elapsed.TotalMilliseconds); + _logger.LogInformation("Finished discovery in '{workspaceFolder}' in {elapsedMilliseconds} milliseconds", workspaceFolder, elapsedMilliseconds); + + // Ensure items go into the cache file in a stable order. + // This is useful for manual inspection and allows use of 'BinarySearch' to match directories against the cache. + newFileBasedAppsBuilder.Sort(); + directoriesContainingCsprojBuilder.Sort(); + var newCache = new Cache(workspaceFolder, walkStartTimeUtc, newFileBasedAppsBuilder.ToImmutableAndFree(), directoriesContainingCsprojBuilder.ToImmutableAndFree()); + try + { + Directory.CreateDirectory(cacheDirectory); + var cacheStagingFilePath = Path.Join(cacheDirectory, "cache.staging.json"); + using (var stagingFile = File.Create(cacheStagingFilePath)) + { + JsonSerializer.Serialize(stagingFile, newCache, CacheSerializerContext.Default.Cache); + } + File.Replace(cacheStagingFilePath, cacheFilePath, destinationBackupFileName: null); + } + catch (Exception ex) when (FatalError.ReportAndCatch(ex)) + { + } + + return newCache.FileBasedAppFullPaths; + } + + /// Check if discovery should consider this a file-based app. + private static bool IsFileBasedApp(string fullPath) + { + using var fileStream = File.OpenRead(fullPath); + var toRead = (int)Math.Min(5, fileStream.Length); + InlineArray5 bytes = default; + Span bytesSpan = bytes; + fileStream.ReadExactly(bytesSpan[..toRead]); + + // Discovery only considers a file to be file-based app, if it starts with either "#!", or UTF-8 BOM followed by "#!". + return bytesSpan is [(byte)'#', (byte)'!', ..] or [0xEF, 0xBB, 0xBF, (byte)'#', (byte)'!']; + } + + private enum CsFileKind + { + None, // Denotes a file that is irrelevant for discovery. Shouldn't appear on a valid 'CsFileInfo' instance. + Directory, + Cs, + Csproj, + } + + private readonly struct CsFileInfo(CsFileKind kind, string path, DateTimeOffset createdOrModifiedTimeUtc) + { + public CsFileKind Kind { get; } = kind; + public string Path { get; } = path; + public DateTimeOffset CreatedOrModifiedTimeUtc { get; } = createdOrModifiedTimeUtc; + } + + private class DirectoryEnumerator(string directory) : FileSystemEnumerator(directory) + { + private CsFileKind GetKind(ref FileSystemEntry entry) + { + if (entry.IsDirectory) + return CsFileKind.Directory; + + var extension = Path.GetExtension(entry.FileName); + if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase)) + return CsFileKind.Cs; + + if (extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + return CsFileKind.Csproj; + + return CsFileKind.None; + } + + protected override CsFileInfo TransformEntry(ref FileSystemEntry entry) + { + var kind = GetKind(ref entry); + Contract.ThrowIfTrue(kind == CsFileKind.None); + return new CsFileInfo(kind, entry.ToFullPath(), Max(entry.CreationTimeUtc, entry.LastWriteTimeUtc)); + } + + protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) + { + return GetKind(ref entry) != CsFileKind.None; + } + + protected override bool ShouldRecurseIntoEntry(ref FileSystemEntry entry) + { + throw ExceptionUtilities.Unreachable(); + } + } + + private class WorkspaceFolderVisitor(Cache cache, ArrayBuilder entryPointsBuilder, ArrayBuilder directoriesContainingCsprojBuilder, ILogger logger) + { + internal void Visit() + // Note: passing `DateTimeOffset.MinValue` here will force `VisitDirectory` to stat the directory again to get its created/modified times out. + => VisitDirectory(cache.WorkspacePath, DateTimeOffset.MinValue); + + private void VisitDirectory(string directory, DateTimeOffset createdOrModifiedTimeUtc) + { + if (Path.GetFileName(directory.AsSpan()).ContainsAny(s_ignoredDirectories)) + return; + + if (createdOrModifiedTimeUtc < cache.LastWalkTimeUtc) + { + // On NTFS, the directory timestamps we observe when enumerating can be stale when files are added/deleted from a directory. + // If we find the timestamps were old enough (i.e. we entered this block), + // we still need to `new DirectoryInfo()` again and force the timestamps to update if needed. + var directoryInfo = new DirectoryInfo(directory); + var newCreatedOrModifiedTimeUtc = Max(directoryInfo.CreationTimeUtc, directoryInfo.LastWriteTimeUtc); + if (newCreatedOrModifiedTimeUtc < cache.LastWalkTimeUtc && cache.DirectoriesContainingCsproj.BinarySearch(directory, s_pathComparer) >= 0) + { + // Our info about this directory is up to date, and we know it contains a csproj, so bail out before enumerating its files. + directoriesContainingCsprojBuilder.Add(directory); + return; + } + + createdOrModifiedTimeUtc = Max(createdOrModifiedTimeUtc, newCreatedOrModifiedTimeUtc); + } + + using var currentDirectoryItems = TemporaryArray.Empty; + using var enumerator = new DirectoryEnumerator(directory); + while (enumerator.MoveNext()) + { + var fileInfo = enumerator.Current; + if (fileInfo.Kind == CsFileKind.Csproj) + { + // Found a csproj. Return without visiting any of the files. + directoriesContainingCsprojBuilder.Add(directory); + return; + } + + currentDirectoryItems.Add(fileInfo); + } + + // Did not find a csproj. Continue searching this subtree for entry points. + foreach (var fileInfo in currentDirectoryItems) + { + // When a subdirectory is moved in to a parent directory between two discovery passes, the timestamps of the subdirectory's files are not updated. + // Only the "modified" timestamp of the parent directory, and the "created" timestamp of the subdirectory, are updated. + // This means: even if a .cs file we encounter within a "new" subdirectory has old timestamps, we don't know whether we've seen it before or not, so we need to crack it. + if (fileInfo.Kind == CsFileKind.Directory) + VisitDirectory(fileInfo.Path, Max(createdOrModifiedTimeUtc, fileInfo.CreatedOrModifiedTimeUtc)); + else if (fileInfo.Kind == CsFileKind.Cs) + VisitCsFile(fileInfo.Path, Max(createdOrModifiedTimeUtc, fileInfo.CreatedOrModifiedTimeUtc)); + else + throw ExceptionUtilities.Unreachable(); + } + } + + private void VisitCsFile(string file, DateTimeOffset createdOrModifiedTimeUtc) + { + if (createdOrModifiedTimeUtc < cache.LastWalkTimeUtc) + { + if (cache.FileBasedAppFullPaths.BinarySearch(file) >= 0) + { + logger.LogInformation("Discovered file-based app (cache hit): {csFilePath}", file); + entryPointsBuilder.Add(file); + } + + return; + } + + if (IOUtilities.PerformIO(() => IsFileBasedApp(file))) + { + logger.LogInformation("Discovered file-based app (cache miss): {csFilePath}", file); + entryPointsBuilder.Add(file); + } + } + } + + /// Get the later of two DateTimeOffsets. + private static DateTimeOffset Max(DateTimeOffset lhs, DateTimeOffset rhs) + => lhs < rhs ? rhs : lhs; + + internal sealed record Cache(string WorkspacePath, DateTimeOffset LastWalkTimeUtc, ImmutableArray FileBasedAppFullPaths, ImmutableArray DirectoriesContainingCsproj); + + [JsonSerializable(typeof(Cache))] + internal sealed partial class CacheSerializerContext : JsonSerializerContext; +} \ No newline at end of file diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index a6e358c8f5d95..2f656bdc7dabd 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -206,19 +206,52 @@ private async ValueTask ClassifyDocumentAsync(string filePath } 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 projectId = projectInfo.Id; - var project = workspace.CurrentSolution.GetRequiredProject(projectId); - var primordialDoc = project.Documents.SingleOrDefault() ?? project.AdditionalDocuments.Single(); var doDesignTimeBuild = !ClassifyAsMiscellaneousFileWithNoReferences(documentFilePath, languageInformation); - await BeginLoadingProjectWithPrimordialAsync(documentFilePath, projectFactory, primordialProjectId: projectId, doDesignTimeBuild); - return primordialDoc; + return await this.GetOrLoadEntryPointDocumentAsync( + documentFilePath, sourceTextLoader, languageInformation, documentInfo.SourceText.ChecksumAlgorithm, doDesignTimeBuild); + } + + /// + /// Used to begin loading a file-based app project for a file-based app on disk, if it hasn't started already, + /// when the caller doesn't need to use any results of the loading process. + /// + public async ValueTask TryBeginLoadingFileBasedAppAsync(string documentFilePath) + { + Contract.ThrowIfFalse(PathUtilities.IsAbsolute(documentFilePath)); + var sourceTextLoader = new WorkspaceFileTextLoader(_workspaceFactory.HostWorkspace.CurrentSolution.Services, documentFilePath, defaultEncoding: null); + var languageInfoProvider = _lspServices.GetRequiredService(); + if (!languageInfoProvider.TryGetLanguageInformation(ProtocolConversions.CreateAbsoluteDocumentUri(documentFilePath), lspLanguageId: "csharp", out var languageInformation)) + { + Contract.Fail($"Could not find language information for '{documentFilePath}'"); + } + + await GetOrLoadEntryPointDocumentAsync(documentFilePath, sourceTextLoader, languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: true); + } + + public async ValueTask GetOrLoadEntryPointDocumentAsync(string documentFilePath, TextLoader textLoader, LanguageInformation languageInformation, SourceHashAlgorithm checksumAlgorithm, bool doDesignTimeBuild) + { + var project = await base.GetOrLoadProjectAsync(documentFilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, CreatePrimordialProjectInfo, doDesignTimeBuild); + return project is null ? null : LookupExistingDocument(project); + + TextDocument? LookupExistingDocument(Project project) + { + var document = project.Documents.FirstOrDefault(document => document.FilePath == documentFilePath) + ?? project.AdditionalDocuments.FirstOrDefault(document => document.FilePath == documentFilePath); + if (document is null) + { + _logger.LogWarning("Could not get a document for '{documentFilePath}' because its project doesn't contain a document for it", documentFilePath); + } + + return document; + } + + ProjectInfo CreatePrimordialProjectInfo(ProjectSystemProjectFactory projectFactory) + { + var enableFileBasedPrograms = GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms); + return MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument( + projectFactory.Workspace, documentFilePath, textLoader, languageInformation, checksumAlgorithm, projectFactory.Workspace.Services.SolutionServices, [], enableFileBasedPrograms); + } } public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri) @@ -231,8 +264,13 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri public async ValueTask CloseDocumentAsync(DocumentUri uri) { + // If automatic discovery is enabled, we don't want to unload a file-based app upon closing a document. + var unloadFromProjectFactory = GlobalOptionService.GetOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery) + ? _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory + : null; + var documentPath = GetDocumentFilePath(uri); - await TryUnloadProjectAsync(documentPath); + await TryUnloadProjectAsync(documentPath, unloadFromProjectFactory); } protected override async Task TryLoadProjectInMSBuildHostAsync( diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs index d09fdf85d4643..06d740c78f8a0 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs @@ -134,21 +134,35 @@ public static string HashWithNormalizedCasing(string text) } } + internal static string GetDiscoveryCacheDirectory(string workspaceFolder) + => GetTempPathCore("runfile-discovery", workspaceFolder); + + internal static string GetDiscoveryCacheRootDirectory() + => GetTempDotnetSubdirectory("runfile-discovery"); + // See https://github.com/dotnet/sdk/blob/5a4292947487a9d34f4256c1d17fb3dc26859174/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L449 internal static string GetArtifactsPath(string entryPointFileFullPath) + => GetTempPathCore("runfile", entryPointFileFullPath); + + private static string GetTempDotnetSubdirectory(string dotnetSubdirectory) { // We want a location where permissions are expected to be restricted to the current user. - string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + string tempDirectory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.GetTempPath() : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Join(tempDirectory, "dotnet", dotnetSubdirectory); + } - // Include entry point file name so the directory name is not completely opaque. - string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath); - string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath); + private static string GetTempPathCore(string dotnetSubdirectory, string originalFilePath) + { + // Include original file name so the directory name is not completely opaque. + string fileName = Path.GetFileNameWithoutExtension(originalFilePath); + string hash = Sha256Hasher.HashWithNormalizedCasing(originalFilePath); string directoryName = $"{fileName}-{hash}"; - return Path.Join(directory, "dotnet", "runfile", directoryName); + return Path.Join(GetTempDotnetSubdirectory(dotnetSubdirectory), directoryName); } + #endregion // https://github.com/dotnet/roslyn/issues/78618: falling back to this until dotnet run-api is more widely available diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index 8f9efa3daecad..ff6a62c049c29 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -406,24 +406,47 @@ async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogIt } } - /// - /// Begins loading a project with an associated primordial project. Must not be called for a project which has already begun loading. - /// - protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, bool doDesignTimeBuild) + protected async ValueTask GetOrLoadProjectAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, Func createPrimordialProjectInfo, bool doDesignTimeBuild) { using (await _gate.DisposableWaitAsync(CancellationToken.None)) { - // If this project has already begun loading, we need to throw. - // This is because we can't ensure that the workspace and project system will remain in a consistent state after this call. - // For example, there could be a need for the project system to track both a primordial project and list of loaded targets, which we don't support. - if (_loadedProjects.ContainsKey(projectPath)) + if (_loadedProjects.TryGetValue(projectPath, out var existingState)) { - Contract.Fail($"Cannot begin loading project '{projectPath}' because it has already begun loading."); + // Note: this generally only happens if we fall through to the "add to misc workspace" path, + // and we lose a race to begin loading the miscellaneous file project. + return LookupExistingProject(existingState); } - _loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectFactory, primordialProjectId)); + var primordialProjectInfo = createPrimordialProjectInfo(primordialProjectFactory); + primordialProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(primordialProjectInfo)); + _loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectFactory, primordialProjectInfo.Id)); if (doDesignTimeBuild) _projectsToReload.AddWork(new ProjectToLoad(projectPath, ProjectGuid: null, ReportTelemetry: true)); + + return primordialProjectFactory.Workspace.CurrentSolution.GetRequiredProject(primordialProjectInfo.Id); + } + + Project? LookupExistingProject(ProjectLoadState loadState) + { + if (loadState is ProjectLoadState.Primordial primordial) + { + return primordial.PrimordialProjectFactory.Workspace.CurrentSolution.GetRequiredProject(primordial.PrimordialProjectId); + } + else if (loadState is ProjectLoadState.LoadedTargets loadedTargets) + { + var target = loadedTargets.LoadedProjectTargets.FirstOrDefault(); + if (target is null) + { + _logger.LogWarning("Could not get a project for '{projectPath}' because it loaded with no targets", projectPath); + return null; + } + + return target.ProjectFactory.Workspace.CurrentSolution.GetRequiredProject(target.ProjectId); + } + else + { + throw ExceptionUtilities.UnexpectedValue(loadState); + } } } diff --git a/src/LanguageServer/Protocol/Features/Options/FileBasedAppsOptionsStorage.cs b/src/LanguageServer/Protocol/Features/Options/FileBasedAppsOptionsStorage.cs new file mode 100644 index 0000000000000..19863917b6e2b --- /dev/null +++ b/src/LanguageServer/Protocol/Features/Options/FileBasedAppsOptionsStorage.cs @@ -0,0 +1,17 @@ +// 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.Options; + +namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; + +internal static class FileBasedAppsOptionsStorage +{ + private static readonly OptionGroup s_optionGroup = new(name: "file_based_apps", description: ""); + + /// + /// Whether to automatically discover and load file-based app entry points. + /// + public static readonly Option2 EnableAutomaticDiscovery = new("dotnet_enable_automatic_discovery", defaultValue: true, s_optionGroup); +} diff --git a/src/LanguageServer/Protocol/Handler/IInitializeManager.cs b/src/LanguageServer/Protocol/Handler/IInitializeManager.cs index 195fa6c63d334..8e724a20f98bf 100644 --- a/src/LanguageServer/Protocol/Handler/IInitializeManager.cs +++ b/src/LanguageServer/Protocol/Handler/IInitializeManager.cs @@ -2,6 +2,7 @@ // 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 Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.LanguageServer.Handler; @@ -14,5 +15,8 @@ internal interface IInitializeManager : ILspService InitializeParams? TryGetInitializeParams(); + /// Expected to be non-default after the Initialize event. + ImmutableArray GetRequiredWorkspaceFolderPaths(); + void SetInitializeParams(InitializeParams initializeParams); } diff --git a/src/LanguageServer/Protocol/Handler/InitializeManager.cs b/src/LanguageServer/Protocol/Handler/InitializeManager.cs index be47ac8ac17b6..c4339d3de5ebf 100644 --- a/src/LanguageServer/Protocol/Handler/InitializeManager.cs +++ b/src/LanguageServer/Protocol/Handler/InitializeManager.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.LanguageServer.Handler; @@ -14,6 +16,7 @@ public InitializeManager() } private InitializeParams? _initializeParams; + private ImmutableArray _workspaceFolderPathsOpt; public ClientCapabilities GetClientCapabilities() { @@ -29,6 +32,22 @@ public void SetInitializeParams(InitializeParams initializeParams) { Contract.ThrowIfFalse(_initializeParams == null); _initializeParams = initializeParams; + _workspaceFolderPathsOpt = initializeParams.WorkspaceFolders is [_, ..] workspaceFolders ? GetFolderPaths(workspaceFolders) : []; + + static ImmutableArray GetFolderPaths(WorkspaceFolder[] workspaceFolders) + { + var builder = ArrayBuilder.GetInstance(workspaceFolders.Length); + foreach (var workspaceFolder in workspaceFolders) + { + if (workspaceFolder.DocumentUri.ParsedUri is not { } parsedUri) + continue; + + var workspaceFolderPath = ProtocolConversions.GetDocumentFilePathFromUri(parsedUri); + builder.Add(workspaceFolderPath); + } + + return builder.ToImmutableAndFree(); + } } public InitializeParams? TryGetInitializeParams() @@ -36,6 +55,12 @@ public void SetInitializeParams(InitializeParams initializeParams) return _initializeParams; } + public ImmutableArray GetRequiredWorkspaceFolderPaths() + { + Contract.ThrowIfTrue(_workspaceFolderPathsOpt.IsDefault, $"{nameof(_workspaceFolderPathsOpt)} was not initialized. Was this accessed before the OnInitialized event ran?"); + return _workspaceFolderPathsOpt; + } + public ClientCapabilities? TryGetClientCapabilities() { return _initializeParams?.Capabilities; diff --git a/src/VisualStudio/Core/Test.Next/Options/VisualStudioOptionStorageTests.cs b/src/VisualStudio/Core/Test.Next/Options/VisualStudioOptionStorageTests.cs index c41c6825761de..050d32fb596b7 100644 --- a/src/VisualStudio/Core/Test.Next/Options/VisualStudioOptionStorageTests.cs +++ b/src/VisualStudio/Core/Test.Next/Options/VisualStudioOptionStorageTests.cs @@ -239,6 +239,7 @@ public void OptionHasStorageIfNecessary(string configName) "dotnet_enable_automatic_restore", // VSCode only option for the VS Code project system; does not apply to VS "dotnet_enable_file_based_programs", // VSCode only option for the VS Code project system; does not apply to VS "dotnet_enable_file_based_programs_when_ambiguous", // VSCode only option for the VS Code project system; does not apply to VS + "dotnet_enable_automatic_discovery", // VSCode only option for the VS Code project system; does not apply to VS "dotnet_lsp_using_devkit", // VSCode internal only option. Does not need any UI. "dotnet_enable_references_code_lens", // VSCode only option. Does not apply to VS. "dotnet_enable_tests_code_lens", // VSCode only option. Does not apply to VS.