Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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.LanguageServer.FileBasedPrograms;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.Test.Utilities;
using Roslyn.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.FileBasedPrograms;

public sealed class CsprojInConeCheckerTests : IDisposable
{
private readonly TempRoot _tempRoot = new();

public void Dispose()
=> _tempRoot.Dispose();

[Fact]
public void UsesCurrentWorkspaceFolders()
{
var initialWorkspace = _tempRoot.CreateDirectory();
var projectWorkspace = _tempRoot.CreateDirectory();
projectWorkspace.CreateFile("Project.csproj");
var sourceFile = projectWorkspace.CreateDirectory("src").CreateFile("Program.cs");
var initialFolder = CreateWorkspaceFolder(initialWorkspace.Path);
var projectFolder = CreateWorkspaceFolder(projectWorkspace.Path);
var tracker = new WorkspaceFolderTracker();
tracker.Update([initialFolder], removedFolders: null);
var checker = new CsprojInConeChecker(tracker);

Assert.False(checker.IsContainedInCsprojCone(sourceFile.Path));

tracker.Update([projectFolder], [initialFolder]);

Assert.True(checker.IsContainedInCsprojCone(sourceFile.Path));
}

private static WorkspaceFolder CreateWorkspaceFolder(string path)
=> new()
{
DocumentUri = ProtocolConversions.CreateAbsoluteDocumentUri(path),
Name = Path.GetFileName(path),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.CodeAnalysis.FileBasedPrograms;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
Expand Down Expand Up @@ -276,13 +277,48 @@ public async Task TestDiscovery_Option_EnableFileBasedPrograms_True()
DeferDeleteCacheDirectory(testLspServer, tempDir.Path);

var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>();
await discovery.FindAndLoadEntryPointsAsync();
await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().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_WorkspaceFoldersChangedRefreshes()
{
var removedWorkspace = _tempRoot.CreateDirectory();
var addedWorkspace = _tempRoot.CreateDirectory();

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(removedWorkspace.Path), Name = "removed" }
]
});
DeferDeleteCacheDirectory(testLspServer, removedWorkspace.Path);
DeferDeleteCacheDirectory(testLspServer, addedWorkspace.Path);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();

var appText = "#!/usr/bin/env dotnet";
var removedApp = removedWorkspace.CreateFile("Removed.cs").WriteAllText(appText);
var addedApp = addedWorkspace.CreateFile("Added.cs").WriteAllText(appText);
var workspaceFolderTracker = testLspServer.GetRequiredLspService<IWorkspaceFolderTracker>();
workspaceFolderTracker.Update(
[new() { DocumentUri = CreateAbsoluteDocumentUri(addedWorkspace.Path), Name = "added" }],
[new() { DocumentUri = CreateAbsoluteDocumentUri(removedWorkspace.Path), Name = "removed" }]);

await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();

AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(removedApp.Path));
var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(addedApp.Path), testLspServer);
Assert.Equal(WorkspaceKind.Host, workspace.Kind);
Assert.NotNull(document);
}

[Fact]
public async Task TestDiscovery_Option_EnableFileBasedPrograms_False()
{
Expand All @@ -309,7 +345,7 @@ public async Task TestDiscovery_Option_EnableFileBasedPrograms_False()
DeferDeleteCacheDirectory(testLspServer, tempDir.Path);

var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>();
await discovery.FindAndLoadEntryPointsAsync();
await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(appFile.Path));
}
Expand All @@ -331,7 +367,7 @@ public async Task TestDiscovery_Option_EnableAutomaticDiscovery_False()
await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path);

var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>();
await discovery.FindAndLoadEntryPointsAsync();
await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(appFile.Path));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ public async Task TestMultipleFileBasedPrograms_WithWorkspaceDiscovery(bool muta
var globalOptions = testLspServer.TestWorkspace.ExportProvider.GetExportedValue<IGlobalOptionService>();
globalOptions.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, true);
var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>();
await discovery.FindAndLoadEntryPointsAsync();
await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();

// Verify all FBAs loaded successfully in the host workspace.
Expand Down Expand Up @@ -1282,7 +1282,7 @@ internal class Util { }
var globalOptions = testLspServer.TestWorkspace.ExportProvider.GetExportedValue<IGlobalOptionService>();
globalOptions.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, true);
var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>();
await discovery.FindAndLoadEntryPointsAsync();
await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();

// Even though the primary file was never opened in the editor,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
Expand All @@ -20,35 +19,28 @@ internal sealed class CsprojInConeCheckerFactory() : ILspServiceFactory
{
public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind)
{
return new CsprojInConeChecker();
return new CsprojInConeChecker(lspServices.GetRequiredService<IWorkspaceFolderTracker>());
}
}

internal sealed class CsprojInConeChecker : ILspService, IOnInitialized
internal sealed class CsprojInConeChecker(IWorkspaceFolderTracker workspaceFolderTracker) : ILspService
{
private ImmutableArray<string> _workspaceFolders;

public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken)
{
var initializeManager = context.GetRequiredService<IInitializeManager>();
_workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths();
return Task.CompletedTask;
}

public bool IsContainedInCsprojCone(string csFilePath)
{
// Note: manual perf testing of this check on Windows, in a reasonably complex case,
// showed an overhead on the order of 100s of microseconds for this check.
// If this overhead becomes problematic, we may want to put a cache in front of it.

Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(IsContainedInCsprojCone)}.");
if (_workspaceFolders.IsEmpty)
// Bound the search to workspace folders so opening an arbitrary file outside the workspace does not
// discover and load an unrelated project from one of its ancestor directories.
var workspaceFolders = workspaceFolderTracker.GetRequiredWorkspaceFolderPaths();
Comment thread
JoeRobich marked this conversation as resolved.
if (workspaceFolders.IsEmpty)
Comment thread
jasonmalinowski marked this conversation as resolved.
return false;

if (!PathUtilities.IsAbsolute(csFilePath))
return false;

foreach (var workspaceFolder in _workspaceFolders)
foreach (var workspaceFolder in workspaceFolders)
{
var directoryName = PathUtilities.GetDirectoryName(csFilePath);
while (PathUtilities.IsSameDirectoryOrChildOf(child: directoryName, parent: workspaceFolder))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.

Expand All @@ -20,6 +20,7 @@
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Threading;
using Microsoft.Extensions.Logging;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
Expand All @@ -38,7 +39,8 @@ public ILspService CreateILspService(LspServices lspServices, WellKnownLspServer
globalOptionService,
listenerProvider.GetListener(FeatureAttribute.Workspace),
lspServices.GetRequiredService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(),
lspServices.GetRequiredService<ILoggerFactory>(),
lspServices.GetRequiredService<ILoggerFactory>().CreateLogger<FileBasedProgramsEntryPointDiscovery>(),
lspServices.GetRequiredService<IWorkspaceFolderTracker>(),
lspServices);
}
}
Expand All @@ -47,8 +49,9 @@ internal sealed partial class FileBasedProgramsEntryPointDiscovery(
IGlobalOptionService globalOptionService,
IAsynchronousOperationListener listener,
IFileBasedProgramService fileBasedProgramService,
ILoggerFactory loggerFactory,
LspServices lspServices) : ILspService, IOnInitialized
ILogger logger,
IWorkspaceFolderTracker workspaceFolderTracker,
LspServices lspServices) : ILspService, IOnInitialized, IDisposable
{
private static readonly StringComparer s_pathComparer = StringComparer.OrdinalIgnoreCase;

Expand All @@ -61,48 +64,56 @@ internal sealed partial class FileBasedProgramsEntryPointDiscovery(
"node_modules"
], StringComparison.OrdinalIgnoreCase);

private readonly ILogger _logger = loggerFactory.CreateLogger<FileBasedProgramsEntryPointDiscovery>();
private ImmutableArray<string> _workspaceFolders;
private readonly AsyncBatchingWorkQueue _discoveryQueue = new(
TimeSpan.Zero,
cancellationToken => FindAndLoadEntryPointsAsync(globalOptionService, fileBasedProgramService, workspaceFolderTracker, lspServices, logger, cancellationToken),
listener);

public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken)
{
var initializeManager = context.GetRequiredService<IInitializeManager>();
_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);
workspaceFolderTracker.WorkspaceFoldersChanged += OnWorkspaceFoldersChanged;
_discoveryQueue.AddWork();

return Task.CompletedTask;
}

internal async Task FindAndLoadEntryPointsAsync()
private void OnWorkspaceFoldersChanged(ImmutableHashSet<string> _)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's a bit strange the event handler is giving us the set and we're ignoring it. That's fine since we'll look at the "final" output, but maybe we just don't need the argument at all?

=> _discoveryQueue.AddWork();

public void Dispose()
{
workspaceFolderTracker.WorkspaceFoldersChanged -= OnWorkspaceFoldersChanged;
_discoveryQueue.Dispose();
}

internal ValueTask FindAndLoadEntryPointsAsync(CancellationToken cancellationToken)
=> FindAndLoadEntryPointsAsync(globalOptionService, fileBasedProgramService, workspaceFolderTracker, lspServices, logger, cancellationToken);

private static async ValueTask FindAndLoadEntryPointsAsync(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wasn't sure why this got made static.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Other than to make the AsyncBatchingWorkQueue code duplicate the logic of the non-static overload.

IGlobalOptionService globalOptionService,
IFileBasedProgramService fileBasedProgramService,
IWorkspaceFolderTracker workspaceFolderTracker,
LspServices lspServices,
ILogger logger,
CancellationToken cancellationToken)
{
Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(FindAndLoadEntryPointsAsync)}.");
var workspaceFolders = workspaceFolderTracker.GetRequiredWorkspaceFolderPaths();

if (_workspaceFolders.IsEmpty)
if (workspaceFolders.IsEmpty)
{
_logger.LogTrace("No workspace folders to search for file-based apps.");
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.");
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.");
logger.LogTrace(@"""dotnet.fileBasedApps.enableAutomaticDiscovery"" is false. Not discovering entry points.");
return;
}

Expand All @@ -111,10 +122,13 @@ internal async Task FindAndLoadEntryPointsAsync()

// 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 workspaceFolder in workspaceFolders)
{
foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder))
cancellationToken.ThrowIfCancellationRequested();

foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder, fileBasedProgramService, logger))
{
cancellationToken.ThrowIfCancellationRequested();
await fileBasedProgramsProjectSystem.TryBeginLoadingFileBasedAppAsync(fileBasedAppPath);
}
}
Expand Down Expand Up @@ -146,6 +160,9 @@ protected override bool ShouldIncludeEntry(ref FileSystemEntry entry)
}

internal ImmutableArray<string> FindEntryPoints(string workspaceFolder)
=> FindEntryPoints(workspaceFolder, fileBasedProgramService, logger);

private static ImmutableArray<string> FindEntryPoints(string workspaceFolder, IFileBasedProgramService fileBasedProgramService, ILogger logger)
{
var stopwatch = SharedStopwatch.StartNew();
var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(workspaceFolder);
Expand All @@ -170,7 +187,7 @@ internal ImmutableArray<string> FindEntryPoints(string workspaceFolder)
}
catch (Exception ex)
{
_logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message);
logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message);
}

cache ??= new Cache(workspaceFolder, DateTimeOffset.MinValue, FileBasedAppFullPaths: [], DirectoriesContainingCsproj: []);
Expand All @@ -193,10 +210,10 @@ internal ImmutableArray<string> FindEntryPoints(string workspaceFolder)

var newFileBasedAppsBuilder = ArrayBuilder<string>.GetInstance(cache.FileBasedAppFullPaths.Length);
var directoriesContainingCsprojBuilder = ArrayBuilder<string>.GetInstance(cache.DirectoriesContainingCsproj.Length);
var visitor = new WorkspaceFolderVisitor(cache, newFileBasedAppsBuilder, directoriesContainingCsprojBuilder, _logger);
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);
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.
Expand Down
4 changes: 0 additions & 4 deletions src/LanguageServer/Protocol/Handler/IInitializeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// 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;
Expand All @@ -15,8 +14,5 @@ internal interface IInitializeManager : ILspService

InitializeParams? TryGetInitializeParams();

/// <summary>Expected to be non-default after the Initialize event.</summary>
ImmutableArray<string> GetRequiredWorkspaceFolderPaths();

void SetInitializeParams(InitializeParams initializeParams);
}
Loading
Loading