diff --git a/tsc/internal/ls/crossproject.go b/tsc/internal/ls/crossproject.go index 17d6bf1c9f178..ac69aaf52ccf5 100644 --- a/tsc/internal/ls/crossproject.go +++ b/tsc/internal/ls/crossproject.go @@ -72,6 +72,7 @@ func (defaultLs *LanguageService) handleCrossProject[Req lsproto.HasTextDocument allProjects := orchestrator.GetAllProjectsForInitialRequest() var results collections.SyncMap[string, *response[Resp]] var defaultDefinition *nonLocalDefinition + defaultDefinitionProjectLoaded := false canSearchProject := func(project Project) bool { _, searched := results.Load(project.Id()) return !searched @@ -242,6 +243,15 @@ func (defaultLs *LanguageService) handleCrossProject[Req lsproto.HasTextDocument return true }) + // An inferred project may reference files whose configured project was + // unloaded when an unrelated file was opened. Load it once before expanding the search. + if !defaultDefinitionProjectLoaded && defaultLs.GetProgram().Options().ConfigFilePath == "" && !tspath.IsDeclarationFileName(defaultDefinition.TextDocumentURI().FileName()) { + if _, errProjects := orchestrator.GetProjectsForFile(ctx, defaultDefinition.TextDocumentURI()); errProjects != nil { + return resp, errProjects + } + defaultDefinitionProjectLoaded = true + } + // Load more projects based on default definition found for loadedProject := range orchestrator.GetProjectsLoadingProjectTree(ctx, &requestedProjectTrees) { if ctx.Err() != nil { diff --git a/tsc/internal/lsp/server_references_updates_test.go b/tsc/internal/lsp/server_references_updates_test.go new file mode 100644 index 0000000000000..af91b9ce6602e --- /dev/null +++ b/tsc/internal/lsp/server_references_updates_test.go @@ -0,0 +1,90 @@ +package lsp_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "gotest.tools/v3/assert" +) + +func TestReferencesOnFromAfterUnrelatedEdit(t *testing.T) { + t.Parallel() + + for _, unsavedEdit := range []bool{false, true} { + name := "fresh session" + if unsavedEdit { + name = "unsaved edit" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + const originalApp = "import React from 'react';\n" + const editedApp = "import React f;\n" + files := map[string]string{ + "/root/tsconfig.json": `{ "include": ["src"], "exclude": ["src/test.ts"] }`, + "/root/src/utils/noop.ts": "export default function noop() {}\n", + "/root/src/test.ts": "import noop from './utils/noop';\n", + "/root/src/form.ts": "import noop from './utils/noop';\nnoop();\n", + "/root/app/tsconfig.json": `{ "include": ["src"] }`, + "/root/app/src/form.ts": editedApp, + } + if unsavedEdit { + files["/root/app/src/form.ts"] = originalApp + } + client, _ := initMutableLSPClient(t, files, &lsutil.UserPreferences{}) + testURI := lsconv.FileNameToDocumentURI("/root/src/test.ts") + appURI := lsconv.FileNameToDocumentURI("/root/app/src/form.ts") + client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: testURI, LanguageId: "typescript", Version: 1, Text: files["/root/src/test.ts"]}, + }) + if unsavedEdit { + // The requesting file is excluded from the main configured project. + // Opening the unrelated app file unloads that project before the search. + client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: appURI, LanguageId: "typescript", Version: 1, Text: originalApp}, + }) + client.SendNotification(t, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ + TextDocument: lsproto.VersionedTextDocumentIdentifier{Uri: appURI, Version: 2}, + ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {Partial: &lsproto.TextDocumentContentChangePartial{ + Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 14}, End: lsproto.Position{Line: 0, Character: 25}}, + Text: "", + }}, + }, + }) + } + + verifyReferences := func() { + t.Helper() + msg, resp, ok := client.SendRequest(t, lsproto.TextDocumentReferencesInfo, &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: testURI}, + Position: lsproto.Position{Line: 0, Character: 14}, // from + Context: &lsproto.ReferenceContext{IncludeDeclaration: true}, + }) + assert.Assert(t, ok, "expected response") + assert.Assert(t, msg.AsResponse().Error == nil) + assert.Assert(t, resp.Locations != nil) + assert.DeepEqual(t, *resp.Locations, []lsproto.Location{ + {Uri: testURI, Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 18}, End: lsproto.Position{Line: 0, Character: 30}}}, + {Uri: lsconv.FileNameToDocumentURI("/root/src/utils/noop.ts"), Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 24}, End: lsproto.Position{Line: 0, Character: 28}}}, + {Uri: lsconv.FileNameToDocumentURI("/root/src/form.ts"), Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 7}, End: lsproto.Position{Line: 0, Character: 11}}}, + {Uri: lsconv.FileNameToDocumentURI("/root/src/form.ts"), Range: lsproto.Range{Start: lsproto.Position{Line: 1, Character: 0}, End: lsproto.Position{Line: 1, Character: 4}}}, + }) + } + verifyReferences() + if unsavedEdit { + client.SendNotification(t, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ + TextDocument: lsproto.VersionedTextDocumentIdentifier{Uri: appURI, Version: 3}, + ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: originalApp}}, + }, + }) + client.SendNotification(t, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: appURI}, + }) + verifyReferences() + } + }) + } +} diff --git a/tsc/internal/project/crossproject_test.go b/tsc/internal/project/crossproject_test.go new file mode 100644 index 0000000000000..d6cef2f9ae707 --- /dev/null +++ b/tsc/internal/project/crossproject_test.go @@ -0,0 +1,95 @@ +package project_test + +import ( + "context" + "iter" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/ls" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" +) + +type referencesTestOrchestrator struct { + session *project.Session + defaultProject ls.Project + initialProjects []ls.Project + definitionLoads int + treeLoads int +} + +func (o *referencesTestOrchestrator) GetDefaultProject() ls.Project { + return o.defaultProject +} + +func (o *referencesTestOrchestrator) GetAllProjectsForInitialRequest() []ls.Project { + return o.initialProjects +} + +func (o *referencesTestOrchestrator) GetLanguageServiceForProjectWithFile(ctx context.Context, p ls.Project, uri lsproto.DocumentUri) *ls.LanguageService { + return o.session.GetLanguageServiceForProjectWithFile(ctx, p.(*project.Project), uri) +} + +func (o *referencesTestOrchestrator) GetProjectsForFile(ctx context.Context, uri lsproto.DocumentUri) ([]ls.Project, error) { + o.definitionLoads++ + return o.session.GetProjectsForFile(ctx, uri) +} + +func (o *referencesTestOrchestrator) GetProjectsLoadingProjectTree(ctx context.Context, requestedProjectTrees *collections.Set[tspath.Path]) iter.Seq[ls.Project] { + o.treeLoads++ + return func(yield func(ls.Project) bool) { + o.session.WithSnapshotLoadingProjectTree(ctx, requestedProjectTrees, func(snapshot *project.Snapshot) { + for _, p := range snapshot.ProjectCollection.LanguageServiceProjects() { + if !yield(p) { + return + } + } + }) + } +} + +func TestReferencesLoadDefinitionProjectOncePerRequest(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + files := map[string]any{ + "/root/tsconfig.json": `{ "include": ["src"], "exclude": ["src/test.ts"] }`, + "/root/src/utils/noop.ts": "export default function noop() {}\n", + "/root/src/test.ts": "import noop from './utils/noop';\n", + "/root/src/form.ts": "import noop from './utils/noop';\nnoop();\n", + "/root/app/tsconfig.json": `{ "include": ["src"] }`, + "/root/app/src/form.ts": "export {};\n", + } + session, _ := projecttestutil.Setup(files) + t.Cleanup(session.Close) + ctx := t.Context() + const testURI = lsproto.DocumentUri("file:///root/src/test.ts") + session.DidOpenFile(ctx, testURI, 1, files[testURI.FileName()].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(ctx, "file:///root/app/src/form.ts", 1, files["/root/app/src/form.ts"].(string), lsproto.LanguageKindTypeScript) + + for range 2 { + defaultProject, service, projects, err := session.GetLanguageServiceAndProjectsForFile(ctx, testURI) + assert.NilError(t, err) + assert.Equal(t, defaultProject.Kind, project.KindInferred) + orchestrator := &referencesTestOrchestrator{session: session, defaultProject: defaultProject, initialProjects: projects} + resp, err := service.ProvideReferences(ctx, &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: testURI}, + Position: lsproto.Position{Line: 0, Character: 14}, // from + Context: &lsproto.ReferenceContext{IncludeDeclaration: true}, + }, orchestrator) + assert.NilError(t, err) + assert.Assert(t, resp.Locations != nil) + assert.Equal(t, len(*resp.Locations), 4) + // Expanding into the configured project requires another work-loop pass. + // Each closed-file load rebuilds the snapshot, even if the project is loaded. + assert.Assert(t, orchestrator.treeLoads > 1) + assert.Equal(t, orchestrator.definitionLoads, 1) + } +}