diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs index 6e503fd6839f6..c118bc3ff1009 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/FileBasedProgramsWorkspaceTests.cs @@ -105,7 +105,7 @@ void M() // Should have the appropriate generated files now that we ran a design time build Assert.Contains(canonicalDocumentOne.Project.Documents, d => d.Name == "Canonical.AssemblyInfo.cs"); - // Add another loose virtual document and verify it goes into the same canonical project. + // Add another loose virtual document and verify it goes into a forked canonical project. var looseFileUriTwo = ProtocolConversions.CreateAbsoluteDocumentUri(@"vscode-notebook-cell://dev-container/test.cs"); await testLspServer.OpenDocumentAsync(looseFileUriTwo, """ class Other @@ -116,16 +116,111 @@ void OtherMethod() } """).ConfigureAwait(false); - // Add another misc file and verify it gets added to the same canonical project. var (_, canonicalDocumentTwo) = await GetLspWorkspaceAndDocumentAsync(looseFileUriTwo, testLspServer).ConfigureAwait(false); Assert.NotNull(canonicalDocumentTwo); - Assert.Equal(canonicalDocumentOne.Project.Id, canonicalDocumentTwo.Project.Id); - // The project should also contain the other misc document. - Assert.Contains(canonicalDocumentTwo.Project.Documents, d => d.Name == looseDocumentOne.Name); - // Should have the appropriate generated files now that we ran a design time build + Assert.NotEqual(canonicalDocumentOne.Project.Id, canonicalDocumentTwo.Project.Id); + Assert.DoesNotContain(canonicalDocumentTwo.Project.Documents, d => d.Name == looseDocumentOne.Name); + // Semantic diagnostics are not expected due to absence of top-level statements + Assert.False(canonicalDocumentTwo.Project.State.HasAllInformation); + // Should have the appropriate generated files from the base misc files project now that we ran a design time build Assert.Contains(canonicalDocumentTwo.Project.Documents, d => d.Name == "Canonical.AssemblyInfo.cs"); } + /// Test that a document which does not have an on-disk path, is never treated as a file-based program. + [Theory, CombinatorialData] + public async Task TestNonFileDocumentsAreNotFileBasedPrograms(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer }); + Assert.Null(await GetMiscellaneousDocumentAsync(testLspServer)); + + var nonFileUri = ProtocolConversions.CreateAbsoluteDocumentUri(@"vscode-notebook-cell://dev-container/test.cs"); + await testLspServer.OpenDocumentAsync(nonFileUri, """ + #:sdk Microsoft.Net.Sdk + Console.WriteLine("Hello World"); + """).ConfigureAwait(false); + + // File should be initially added as a primordial document in the canonical misc files project with no metadata references. + var (_, primordialDocument) = await GetRequiredLspWorkspaceAndDocumentAsync(nonFileUri, testLspServer).ConfigureAwait(false); + // Should have the primordial canonical document and the loose document. + Assert.Equal(2, primordialDocument.Project.Documents.Count()); + Assert.Empty(primordialDocument.Project.MetadataReferences); + + var primordialSyntaxTree = await primordialDocument.GetRequiredSyntaxTreeAsync(CancellationToken.None); + // TODO: we probably don't want to report syntax errors for '#:' in the primordial non-file document. + // The logic which decides whether to add '-features:FileBasedProgram' probably needs to be adjusted. + primordialSyntaxTree.GetDiagnostics(CancellationToken.None).Verify( + // vscode-notebook-cell://dev-container/test.cs(1,2): error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')" + // #:sdk Microsoft.Net.Sdk + TestHelpers.Diagnostic(code: 9298, squiggledText: ":").WithLocation(1, 2)); + + // Wait for the canonical project to finish loading. + await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); + + // Verify the document is loaded in the canonical project. + var (miscWorkspace, canonicalDocument) = await GetRequiredLspWorkspaceAndDocumentAsync(nonFileUri, testLspServer).ConfigureAwait(false); + Assert.Equal(WorkspaceKind.MiscellaneousFiles, miscWorkspace.Kind); + Assert.NotNull(canonicalDocument); + Assert.NotEqual(primordialDocument, canonicalDocument); + // Should have the appropriate generated files now that we ran a design time build + Assert.Contains(canonicalDocument.Project.Documents, d => d.Name == "Canonical.AssemblyInfo.cs"); + + var canonicalSyntaxTree = await canonicalDocument.GetRequiredSyntaxTreeAsync(CancellationToken.None); + // TODO: we probably don't want to report syntax errors for '#:' in the canonical non-file document. + // The logic which decides whether to add '-features:FileBasedProgram' probably needs to be adjusted. + canonicalSyntaxTree.GetDiagnostics(CancellationToken.None).Verify( + // vscode-notebook-cell://dev-container/test.cs(1,2): error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')" + // #:sdk Microsoft.Net.Sdk + TestHelpers.Diagnostic(code: 9298, squiggledText: ":").WithLocation(1, 2)); + } + + [Theory, CombinatorialData] + public async Task TestSemanticDiagnosticsEnabledWhenTopLevelStatementsAdded(bool mutatingLspWorkspace) + { + // Create a server that supports LSP misc files and verify no misc files present. + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer }); + Assert.Null(await GetMiscellaneousDocumentAsync(testLspServer)); + + var looseFileUriOne = ProtocolConversions.CreateAbsoluteDocumentUri(@"C:\SomeFile.cs"); + await testLspServer.OpenDocumentAsync(looseFileUriOne, """ + class C { } + """).ConfigureAwait(false); + + // File should be initially added as a primordial document in the canonical misc files project with no metadata references. + var (miscFilesWorkspace, looseDocumentOne) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false); + Assert.Equal(WorkspaceKind.MiscellaneousFiles, miscFilesWorkspace.Kind); + // Should have the primordial canonical document and the loose document. + Assert.Equal(2, looseDocumentOne.Project.Documents.Count()); + Assert.Empty(looseDocumentOne.Project.MetadataReferences); + // Semantic diagnostics are not expected because we haven't loaded references + Assert.False(looseDocumentOne.Project.State.HasAllInformation); + + // Wait for the canonical project to finish loading. + await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); + + // Verify the document is loaded in the canonical project. + var (_, canonicalDocumentOne) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false); + Assert.NotEqual(looseDocumentOne, canonicalDocumentOne); + // Should have the appropriate generated files now that we ran a design time build + Assert.Contains(canonicalDocumentOne.Project.Documents, d => d.Name == "Canonical.AssemblyInfo.cs"); + // There are no top-level statements, so semantic errors are still not expected. + Assert.False(canonicalDocumentOne.Project.State.HasAllInformation); + + // Adding a top-level statement to a misc file causes it to report semantic errors. + var textToInsert = $"""Console.WriteLine("Hello World!");{Environment.NewLine}"""; + await testLspServer.InsertTextAsync(looseFileUriOne, (Line: 0, Column: 0, Text: textToInsert)); + var (workspace, canonicalDocumentTwo) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false); + Assert.Equal(""" + Console.WriteLine("Hello World!"); + class C { } + """, + (await canonicalDocumentTwo.GetSyntaxRootAsync())!.ToFullString()); + Assert.Equal(WorkspaceKind.MiscellaneousFiles, workspace.Kind); + // When presence of top-level statements changes, the misc project is forked again in order to change attributes. + Assert.NotEqual(canonicalDocumentOne.Project.Id, canonicalDocumentTwo.Project.Id); + // Now that it has top-level statements, it should be considered to have all information. + Assert.True(canonicalDocumentTwo.Project.State.HasAllInformation); + } + [Theory, CombinatorialData] public async Task TestFileBecomesFileBasedProgramWhenDirectiveAdded(bool mutatingLspWorkspace) { @@ -144,15 +239,21 @@ await testLspServer.OpenDocumentAsync(looseFileUriOne, """ // Should have the primordial canonical document and the loose document. Assert.Equal(2, looseDocumentOne.Project.Documents.Count()); Assert.Empty(looseDocumentOne.Project.MetadataReferences); + // Semantic diagnostics are not expected because we haven't loaded references + Assert.False(looseDocumentOne.Project.State.HasAllInformation); // Wait for the canonical project to finish loading. await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); // Verify the document is loaded in the canonical project. - var (_, canonicalDocumentOne) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false); + (miscFilesWorkspace, var canonicalDocumentOne) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false); Assert.NotEqual(looseDocumentOne, canonicalDocumentOne); // Should have the appropriate generated files now that we ran a design time build Assert.Contains(canonicalDocumentOne.Project.Documents, d => d.Name == "Canonical.AssemblyInfo.cs"); + // This is not loaded as a file-based program (no dedicated restore done for it etc.), so it should be in the misc workspace. + Assert.Equal(WorkspaceKind.MiscellaneousFiles, miscFilesWorkspace.Kind); + // Because we have top-level statements, it should be considered to have all information (semantic diagnostics should be reported etc.) + Assert.True(canonicalDocumentOne.Project.State.HasAllInformation); // Adding a #! directive to a misc file causes it to move to a file-based program project. var textToInsert = $"#!/usr/bin/env dotnet{Environment.NewLine}"; @@ -180,5 +281,7 @@ await testLspServer.OpenDocumentAsync(looseFileUriOne, """ Assert.Equal(WorkspaceKind.Host, hostWorkspace!.Kind); Assert.NotEqual(fileBasedProject.Id, fullFileBasedDocumentOne!.Project.Id); Assert.Contains(fullFileBasedDocumentOne!.Project.Documents, d => d.Name == "SomeFile.AssemblyInfo.cs"); + // Because it is loaded as a file-based program, it should be considered to have all information (semantic diagnostics should be reported etc.) + Assert.True(canonicalDocumentOne.Project.State.HasAllInformation); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs index 74eed1693aa54..a9fcc49c0fc96 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Immutable; -using System.Security; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Features.Workspaces; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; @@ -82,24 +82,22 @@ public async ValueTask AddMiscellaneousDocumentAsync(string docume { if (loadState is ProjectLoadState.LoadedTargets loadedTargets) { - // Case 1: Project is fully loaded with targets - + // Case 1: Canonical project is fully loaded with targets // We always expect that the canonical project is either Primordial, or loaded with exactly 1 target (1 TFM). Contract.ThrowIfFalse(loadedTargets.LoadedProjectTargets.Length == 1, "Expected exactly one loaded target for canonical project"); - var loadedProjectId = loadedTargets.LoadedProjectTargets.Single().ProjectId; - return await AddDocumentToExistingProject_NoLockAsync(documentPath, documentText, loadedProjectId, cancellationToken); + return await ForkCanonicalProjectAndAddDocument_NoLockAsync(documentPath, documentText, cancellationToken); } else { - // Case 2: Primordial project was already created, but hasn't finished loading. + // Case 2: Primordial canonical project was already created, but hasn't finished loading. var primordialTarget = loadState as ProjectLoadState.Primordial; Contract.ThrowIfNull(primordialTarget, "Expected primordial target"); - return await AddDocumentToExistingProject_NoLockAsync(documentPath, documentText, primordialTarget.PrimordialProjectId, cancellationToken); + return await AddDocumentToPrimordialProject_NoLockAsync(documentPath, documentText, primordialTarget.PrimordialProjectId, cancellationToken); } } else { - // Case 3: Project doesn't exist at all + // Case 3: Canonical project doesn't exist at all return CreatePrimordialProjectAndAddDocument_NoLock(documentPath, documentText); } }, cancellationToken); @@ -138,7 +136,38 @@ await _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.ApplyChangeToW }, cancellationToken); } - private async ValueTask AddDocumentToExistingProject_NoLockAsync(string documentPath, SourceText documentText, ProjectId existingProjectId, CancellationToken cancellationToken) + private async ValueTask ForkCanonicalProjectAndAddDocument_NoLockAsync(string documentPath, SourceText documentText, CancellationToken cancellationToken) + { + var newProjectId = ProjectId.CreateNewId(debugName: $"Forked Misc Project for '{documentPath}'"); + var newDocumentInfo = DocumentInfo.Create( + DocumentId.CreateNewId(newProjectId), + name: Path.GetFileName(documentPath), + loader: TextLoader.From(TextAndVersion.Create(documentText, VersionStamp.Create())), + filePath: documentPath); + + var forkedProjectInfo = await GetForkedProjectInfoAsync(GetCanonicalProject(), newDocumentInfo, documentText, cancellationToken); + + await _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.ApplyChangeToWorkspaceAsync(workspace => + { + workspace.OnProjectAdded(forkedProjectInfo); + }, cancellationToken); + + var miscWorkspace = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace; + var addedDocument = miscWorkspace.CurrentSolution.GetRequiredDocument(newDocumentInfo.Id); + return addedDocument; + } + + internal async ValueTask IsCanonicalProjectLoadedAsync(CancellationToken cancellationToken) + { + return await ExecuteUnderGateAsync(async loadedProjects => + { + var canonicalDocumentPath = _canonicalDocumentPath.Value; + return loadedProjects.TryGetValue(canonicalDocumentPath, out var loadState) + && loadState is ProjectLoadState.LoadedTargets; + }, cancellationToken); + } + + private async ValueTask AddDocumentToPrimordialProject_NoLockAsync(string documentPath, SourceText documentText, ProjectId existingProjectId, CancellationToken cancellationToken) { var miscWorkspace = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace; var documentInfo = DocumentInfo.Create( @@ -242,12 +271,15 @@ protected override ValueTask OnProjectUnloadedAsync(string projectFilePath) return ValueTask.CompletedTask; } - protected override async ValueTask TransitionPrimordialProjectToLoadedAsync( + protected override async ValueTask TransitionPrimordialProjectToLoaded_NoLockAsync( string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, CancellationToken cancellationToken) { + // We only pass 'doDesignTimeBuild: true' for the canonical project. So that's the only time we should get called back for this. + Contract.ThrowIfFalse(projectPath == _canonicalDocumentPath.Value); + // Transfer any misc documents from the primordial project to the loaded canonical project var primordialWorkspace = primordialProjectFactory.Workspace; var primordialProject = primordialWorkspace.CurrentSolution.GetRequiredProject(primordialProjectId); @@ -262,17 +294,8 @@ protected override async ValueTask TransitionPrimordialProjectToLoadedAsync( foreach (var miscDoc in miscDocuments) { - var text = await miscDoc.GetTextAsync(cancellationToken); - var documentInfo = DocumentInfo.Create( - DocumentId.CreateNewId(loadedProjectId), - name: miscDoc.Name, - loader: TextLoader.From(TextAndVersion.Create(text, VersionStamp.Create())), - filePath: miscDoc.FilePath); - - await _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.ApplyChangeToWorkspaceAsync(workspace => - { - workspace.OnDocumentAdded(documentInfo); - }, cancellationToken); + Contract.ThrowIfNull(miscDoc.FilePath); + await ForkCanonicalProjectAndAddDocument_NoLockAsync(miscDoc.FilePath, await miscDoc.GetTextAsync(cancellationToken), cancellationToken); } // Now remove the primordial project @@ -281,6 +304,51 @@ await primordialProjectFactory.ApplyChangeToWorkspaceAsync( cancellationToken); } + /// + /// Creates a new project based on the canonical project with a new document added. + /// This should only be called when the canonical project is in the FullyLoaded state. + /// + private static async Task GetForkedProjectInfoAsync(Project canonicalProject, DocumentInfo newDocumentInfo, SourceText documentText, CancellationToken cancellationToken) + { + var newDocumentPath = newDocumentInfo.FilePath; + Contract.ThrowIfNull(newDocumentPath); + + var forkedProjectId = ProjectId.CreateNewId(debugName: $"Forked Misc Project for '{newDocumentPath}'"); + var syntaxTree = CSharpSyntaxTree.ParseText(text: documentText, canonicalProject.ParseOptions as CSharpParseOptions, path: newDocumentPath, cancellationToken); + var hasAllInformation = await VirtualProjectXmlProvider.HasTopLevelStatementsAsync(syntaxTree, cancellationToken); + var forkedProjectAttributes = new ProjectInfo.ProjectAttributes( + newDocumentInfo.Id.ProjectId, + version: VersionStamp.Create(), + name: canonicalProject.Name, + assemblyName: canonicalProject.AssemblyName, + language: canonicalProject.Language, + compilationOutputInfo: default, + checksumAlgorithm: SourceHashAlgorithm.Sha1, + filePath: newDocumentPath, + outputFilePath: canonicalProject.OutputFilePath, + outputRefFilePath: canonicalProject.OutputRefFilePath, + hasAllInformation: hasAllInformation); + + var forkedProjectInfo = ProjectInfo.Create( + attributes: forkedProjectAttributes, + compilationOptions: canonicalProject.CompilationOptions, + parseOptions: canonicalProject.ParseOptions, + documents: [newDocumentInfo, .. await Task.WhenAll(canonicalProject.Documents.Select(document => GetDocumentInfoAsync(document, document.FilePath)))], + projectReferences: canonicalProject.ProjectReferences, + metadataReferences: canonicalProject.MetadataReferences, + analyzerReferences: canonicalProject.AnalyzerReferences, + analyzerConfigDocuments: await canonicalProject.AnalyzerConfigDocuments.SelectAsArrayAsync(async document => await GetDocumentInfoAsync(document, document.FilePath)), + additionalDocuments: await canonicalProject.AdditionalDocuments.SelectAsArrayAsync(async document => await GetDocumentInfoAsync(document, document.FilePath))); + return forkedProjectInfo; + + async Task GetDocumentInfoAsync(TextDocument document, string? documentPath) => + DocumentInfo.Create( + DocumentId.CreateNewId(forkedProjectId), + name: Path.GetFileName(documentPath) ?? "", + loader: TextLoader.From(TextAndVersion.Create(await document.GetTextAsync(cancellationToken).ConfigureAwait(false), VersionStamp.Create())), + filePath: documentPath); + } + private Project GetCanonicalProject() { var miscWorkspace = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index 57ce37294adcb..643f7ae0ce796 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -71,7 +71,7 @@ public FileBasedProgramsProjectSystem( private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString; - public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken) + public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument textDocument, CancellationToken cancellationToken) { // There are a few cases here: // 1. The document is a primordial document (either not loaded yet or doesn't support design time build) - it will be in the misc files workspace. @@ -79,17 +79,36 @@ public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument docu // 3. The document is loaded as a file based program - then it will be in the main workspace where the project path matches the source file path. // NB: The FileBasedProgramsProjectSystem uses the document file path (the on-disk path) as the projectPath in 'IsProjectLoadedAsync'. - var isLoadedAsFileBasedProgram = document.FilePath is { } filePath && await IsProjectLoadedAsync(filePath, cancellationToken); + var isLoadedAsFileBasedProgram = textDocument.FilePath is { } filePath && await IsProjectLoadedAsync(filePath, cancellationToken); // If this document has a file-based program syntactic marker, but we aren't loading it in a file-based programs project, // we need the caller to remove and re-add this document, so that it gets put in a file-based programs project instead. // See the check in 'LspWorkspaceManager.GetLspDocumentInfoAsync', which removes a document based on 'IsMiscellaneousFilesDocumentAsync' result, // then calls 'GetLspDocumentInfoAsync' again for the same request. - if (!isLoadedAsFileBasedProgram && VirtualProjectXmlProvider.IsFileBasedProgram(await document.GetTextAsync(cancellationToken))) + if (!isLoadedAsFileBasedProgram && VirtualProjectXmlProvider.IsFileBasedProgram(await textDocument.GetTextAsync(cancellationToken))) return false; - if (document.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace) + if (textDocument.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace) + { + // Do a check to determine if the misc project needs to be re-created with a new HasAllInformation flag value. + if (!isLoadedAsFileBasedProgram + && await _canonicalMiscFilesLoader.IsCanonicalProjectLoadedAsync(cancellationToken) + && textDocument is Document document + && await document.GetSyntaxTreeAsync(cancellationToken) is { } syntaxTree) + { + var newHasAllInformation = await VirtualProjectXmlProvider.HasTopLevelStatementsAsync(syntaxTree, cancellationToken); + if (newHasAllInformation != document.Project.State.HasAllInformation) + { + // TODO: replace this method and the call site in LspWorkspaceManager, + // with a mechanism for "updating workspace state if needed" based on changes to a document. + // Perhaps this could be based on actually listening for changes to particular documents, rather than whenever an LSP request related to a document comes in. + // We should be able to do more incremental updates in more cases, rather than needing to throw things away and start over. + return false; + } + } + return true; + } if (isLoadedAsFileBasedProgram) return true; @@ -205,7 +224,7 @@ protected override ValueTask OnProjectUnloadedAsync(string projectFilePath) return ValueTask.CompletedTask; } - protected override async ValueTask TransitionPrimordialProjectToLoadedAsync( + protected override async ValueTask TransitionPrimordialProjectToLoaded_NoLockAsync( string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs index 9375c8c2e6611..dc55c129c7ca7 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs @@ -12,6 +12,7 @@ using System.Text.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Text; @@ -119,6 +120,15 @@ internal static bool IsFileBasedProgram(SourceText text) return false; } + internal static async Task HasTopLevelStatementsAsync(SyntaxTree tree, CancellationToken cancellationToken) + { + var root = await tree.GetRootAsync(cancellationToken); + if (root is CompilationUnitSyntax compilationUnit) + return compilationUnit.Members.Any(member => member.IsKind(SyntaxKind.GlobalStatement)); + + return false; + } + #region Temporary copy of subset of dotnet run-api behavior for fallback: https://github.com/dotnet/roslyn/issues/78618 // See https://github.com/dotnet/sdk/blob/b5dbc69cc28676ac6ea615654c8016a11b75e747/src/Cli/Microsoft.DotNet.Cli.Utils/Sha256Hasher.cs#L10 private static class Sha256Hasher diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index d0ff16d0b192d..16682a61fe3de 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -217,7 +217,7 @@ internal sealed record RemoteProjectLoadResult /// Called when transitioning from a primordial project to loaded targets. /// Subclasses can override this to transfer documents or perform other operations before the primordial project is removed. /// - protected abstract ValueTask TransitionPrimordialProjectToLoadedAsync( + protected abstract ValueTask TransitionPrimordialProjectToLoaded_NoLockAsync( string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, @@ -328,7 +328,7 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr if (currentLoadState is ProjectLoadState.Primordial(var primordialProjectFactory, var projectId)) { // Transition from primordial to loaded state - await TransitionPrimordialProjectToLoadedAsync(projectPath, primordialProjectFactory, projectId, cancellationToken); + await TransitionPrimordialProjectToLoaded_NoLockAsync(projectPath, primordialProjectFactory, projectId, cancellationToken); } // At this point we expect that all the loaded projects are now in the project factory returned, and any previous ones have been removed. diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs index 2e566ee82cfd9..33f11671c83e4 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs @@ -106,7 +106,7 @@ protected override ValueTask OnProjectUnloadedAsync(string projectFilePath) return ValueTask.CompletedTask; } - protected override async ValueTask TransitionPrimordialProjectToLoadedAsync( + protected override async ValueTask TransitionPrimordialProjectToLoaded_NoLockAsync( string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId,