diff --git a/internal/fourslash/tests/codeLensAcrossProjects_test.go b/internal/fourslash/tests/codeLensAcrossProjects_test.go new file mode 100644 index 0000000000..e8d5a4fccc --- /dev/null +++ b/internal/fourslash/tests/codeLensAcrossProjects_test.go @@ -0,0 +1,56 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/ls/lsutil" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestCodeLensOnFunctionAcrossProjects1(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @filename: ./a/tsconfig.json +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMaps": true, + "outDir": "./dist", + "rootDir": "src" + }, + "include": ["./src"] +} + +// @filename: ./a/src/foo.ts +export function aaa() {} +aaa(); + +// @filename: ./b/tsconfig.json +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMaps": true, + "outDir": "./dist", + "rootDir": "src" + }, + "references": [{ "path": "../a" }], + "include": ["./src"] +} + +// @filename: ./b/src/bar.ts +import * as foo from '../../a/dist/foo.js'; +foo.aaa(); +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + + f.VerifyBaselineCodeLens(t, &lsutil.UserPreferences{ + CodeLens: lsutil.CodeLensUserPreferences{ + ReferencesCodeLensEnabled: true, + }, + }) +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 87d031fb11..9d1dcb2b56 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -16,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/jsonutil" "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/ls" @@ -652,222 +653,257 @@ type response[Resp any] struct { forOriginalLocation bool } -func registerMultiProjectReferenceRequestHandler[Req lsproto.HasTextDocumentPosition, Resp any]( - handlers handlerMap, - info lsproto.RequestInfo[Req, Resp], - fn func(*Server, context.Context, *ls.LanguageService, Req, *ast.Node, []*ls.SymbolAndEntries) (Resp, error), - combineResults func(iter.Seq[Resp]) Resp, -) { - handlers[info.Method] = func(s *Server, ctx context.Context, req *lsproto.RequestMessage) error { - var params Req - // Ignore empty params. - if req.Params != nil { - params = req.Params.(Req) - } - // !!! sheetal: multiple projects that contain the file through symlinks - defaultProject, defaultLs, allProjects, err := s.session.GetLanguageServiceAndProjectsForFile(ctx, params.TextDocumentURI()) - if err != nil { - return err - } - defer s.recover(req) +// multiProjectSearchHelper performs a multi-project search for symbols at a given position. +// It returns an iterator that yields results from all relevant projects. +func multiProjectSearchHelper[Resp any]( + s *Server, + ctx context.Context, + uri lsproto.DocumentUri, + position lsproto.Position, + isRename bool, + processFn func(context.Context, *ls.LanguageService, *ast.Node, []*ls.SymbolAndEntries) (Resp, error), +) (iter.Seq[Resp], error) { + defaultProject, defaultLs, allProjects, err := s.session.GetLanguageServiceAndProjectsForFile(ctx, uri) + if err != nil { + return nil, err + } - var results collections.SyncMap[tspath.Path, *response[Resp]] - var defaultDefinition *ls.NonLocalDefinition - canSearchProject := func(project *project.Project) bool { - _, searched := results.Load(project.Id()) - return !searched + var results collections.SyncMap[tspath.Path, *response[Resp]] + var defaultDefinition *ls.NonLocalDefinition + canSearchProject := func(project *project.Project) bool { + _, searched := results.Load(project.Id()) + return !searched + } + wg := core.NewWorkGroup(false) + var errMu sync.Mutex + var enqueueItem func(item projectAndTextDocumentPosition) + enqueueItem = func(item projectAndTextDocumentPosition) { + var response response[Resp] + if _, loaded := results.LoadOrStore(item.project.Id(), &response); loaded { + return } - wg := core.NewWorkGroup(false) - var errMu sync.Mutex - var enqueueItem func(item projectAndTextDocumentPosition) - enqueueItem = func(item projectAndTextDocumentPosition) { - var response response[Resp] - if _, loaded := results.LoadOrStore(item.project.Id(), &response); loaded { + wg.Queue(func() { + if ctx.Err() != nil { return } - wg.Queue(func() { - if ctx.Err() != nil { + // Process the item + ls := item.ls + if ls == nil { + // Get it now + ls = s.session.GetLanguageServiceForProjectWithFile(ctx, item.project, item.Uri) + if ls == nil { return } - defer s.recover(req) - // Process the item - ls := item.ls - if ls == nil { - // Get it now - ls = s.session.GetLanguageServiceForProjectWithFile(ctx, item.project, item.Uri) - if ls == nil { - return + } + originalNode, symbolsAndEntries, ok := ls.ProvideSymbolsAndEntries(ctx, item.Uri, item.Position, isRename) + if ok { + for _, entry := range symbolsAndEntries { + // Find the default definition that can be in another project + // Later we will use this load ancestor tree that references this location and expand search + if item.project == defaultProject && defaultDefinition == nil { + defaultDefinition = ls.GetNonLocalDefinition(ctx, entry) } - } - originalNode, symbolsAndEntries, ok := ls.ProvideSymbolsAndEntries(ctx, item.Uri, item.Position, info.Method == lsproto.MethodTextDocumentRename) - if ok { - for _, entry := range symbolsAndEntries { - // Find the default definition that can be in another project - // Later we will use this load ancestor tree that references this location and expand search - if item.project == defaultProject && defaultDefinition == nil { - defaultDefinition = ls.GetNonLocalDefinition(ctx, entry) + ls.ForEachOriginalDefinitionLocation(ctx, entry, func(uri lsproto.DocumentUri, position lsproto.Position) { + // Get default configured project for this file + defProjects, errProjects := s.session.GetProjectsForFile(ctx, uri) + if errProjects != nil { + return } - ls.ForEachOriginalDefinitionLocation(ctx, entry, func(uri lsproto.DocumentUri, position lsproto.Position) { - // Get default configured project for this file - defProjects, errProjects := s.session.GetProjectsForFile(ctx, uri) - if errProjects != nil { - return - } - for _, defProject := range defProjects { - // Optimization: don't enqueue if will be discarded - if canSearchProject(defProject) { - enqueueItem(projectAndTextDocumentPosition{ - project: defProject, - Uri: uri, - Position: position, - forOriginalLocation: true, - }) - } + for _, defProject := range defProjects { + // Optimization: don't enqueue if will be discarded + if canSearchProject(defProject) { + enqueueItem(projectAndTextDocumentPosition{ + project: defProject, + Uri: uri, + Position: position, + forOriginalLocation: true, + }) } - }) - } + } + }) } + } - if result, errSearch := fn(s, ctx, ls, params, originalNode, symbolsAndEntries); errSearch == nil { - response.complete = true - response.result = result - response.forOriginalLocation = item.forOriginalLocation - } else { - errMu.Lock() - defer errMu.Unlock() - if err != nil { - err = errSearch - } + if result, errSearch := processFn(ctx, ls, originalNode, symbolsAndEntries); errSearch == nil { + response.complete = true + response.result = result + response.forOriginalLocation = item.forOriginalLocation + } else { + errMu.Lock() + defer errMu.Unlock() + if err == nil { + err = errSearch } + } + }) + } + + // Initial set of projects and locations in the queue, starting with default project + enqueueItem(projectAndTextDocumentPosition{ + project: defaultProject, + ls: defaultLs, + Uri: uri, + Position: position, + }) + for _, project := range allProjects { + if project != defaultProject { + enqueueItem(projectAndTextDocumentPosition{ + project: project, + // TODO!! symlinks need to change the URI + Uri: uri, + Position: position, }) } + } - // Initial set of projects and locations in the queue, starting with default project - enqueueItem(projectAndTextDocumentPosition{ - project: defaultProject, - ls: defaultLs, - Uri: params.TextDocumentURI(), - Position: params.TextDocumentPosition(), - }) - for _, project := range allProjects { - if project != defaultProject { - enqueueItem(projectAndTextDocumentPosition{ - project: project, - // TODO!! symlinks need to change the URI - Uri: params.TextDocumentURI(), - Position: params.TextDocumentPosition(), - }) - } + // Outer loop - to complete work if more is added after completing existing queue + for { + // Process existing known projects first + wg.RunAndWait() + if ctx.Err() != nil { + return nil, ctx.Err() + } + // No need to use mu here since we are not in parallel at this point + if err != nil { + return nil, err } - getResultsIterator := func() iter.Seq[Resp] { - return func(yield func(Resp) bool) { - var seenProjects collections.SyncSet[tspath.Path] - if response, loaded := results.Load(defaultProject.Id()); loaded && response.complete { - if !yield(response.result) { - return - } + wg = core.NewWorkGroup(false) + hasMoreWork := false + if defaultDefinition != nil { + requestedProjectTrees := make(map[tspath.Path]struct{}) + results.Range(func(key tspath.Path, response *response[Resp]) bool { + if response.complete { + requestedProjectTrees[key] = struct{}{} } - seenProjects.Add(defaultProject.Id()) - for _, project := range allProjects { - if seenProjects.AddIfAbsent(project.Id()) { - if response, loaded := results.Load(project.Id()); loaded && response.complete { - if !yield(response.result) { - return - } - } - } + return true + }) + + // Load more projects based on default definition found + for _, loadedProject := range s.session.GetSnapshotLoadingProjectTree(ctx, requestedProjectTrees).ProjectCollection.Projects() { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + // Can loop forever without this (enqueue here, dequeue above, repeat) + if !canSearchProject(loadedProject) || loadedProject.GetProgram() == nil { + continue + } + + // Enqueue the project and location for further processing + if loadedProject.HasFile(defaultDefinition.TextDocumentURI().FileName()) { + enqueueItem(projectAndTextDocumentPosition{ + project: loadedProject, + Uri: defaultDefinition.TextDocumentURI(), + Position: defaultDefinition.TextDocumentPosition(), + }) + hasMoreWork = true + } else if sourcePos := defaultDefinition.GetSourcePosition(); sourcePos != nil && loadedProject.HasFile(sourcePos.TextDocumentURI().FileName()) { + enqueueItem(projectAndTextDocumentPosition{ + project: loadedProject, + Uri: sourcePos.TextDocumentURI(), + Position: sourcePos.TextDocumentPosition(), + }) + hasMoreWork = true + } else if generatedPos := defaultDefinition.GetGeneratedPosition(); generatedPos != nil && loadedProject.HasFile(generatedPos.TextDocumentURI().FileName()) { + enqueueItem(projectAndTextDocumentPosition{ + project: loadedProject, + Uri: generatedPos.TextDocumentURI(), + Position: generatedPos.TextDocumentPosition(), + }) + hasMoreWork = true } - // Prefer the searches from locations for default definition - results.Range(func(key tspath.Path, response *response[Resp]) bool { - if !response.forOriginalLocation && seenProjects.AddIfAbsent(key) && response.complete { - return yield(response.result) - } - return true - }) - // Then the searches from original locations - results.Range(func(key tspath.Path, response *response[Resp]) bool { - if response.forOriginalLocation && seenProjects.AddIfAbsent(key) && response.complete { - return yield(response.result) - } - return true - }) } } + if !hasMoreWork { + break + } + } - // Outer loop - to complete work if more is added after completing existing queue - for { - // Process existing known projects first - wg.RunAndWait() - if ctx.Err() != nil { - return ctx.Err() + getResultsIterator := func() iter.Seq[Resp] { + return func(yield func(Resp) bool) { + var seenProjects collections.SyncSet[tspath.Path] + if response, loaded := results.Load(defaultProject.Id()); loaded && response.complete { + if !yield(response.result) { + return + } } - // No need to use mu here since we are not in parallel at this point - if err != nil { - return err + seenProjects.Add(defaultProject.Id()) + for _, project := range allProjects { + if seenProjects.AddIfAbsent(project.Id()) { + if response, loaded := results.Load(project.Id()); loaded && response.complete { + if !yield(response.result) { + return + } + } + } } + // Prefer the searches from locations for default definition + results.Range(func(key tspath.Path, response *response[Resp]) bool { + if !response.forOriginalLocation && seenProjects.AddIfAbsent(key) && response.complete { + return yield(response.result) + } + return true + }) + // Then the searches from original locations + results.Range(func(key tspath.Path, response *response[Resp]) bool { + if response.forOriginalLocation && seenProjects.AddIfAbsent(key) && response.complete { + return yield(response.result) + } + return true + }) + } + } - wg = core.NewWorkGroup(false) - hasMoreWork := false - if defaultDefinition != nil { - requestedProjectTrees := make(map[tspath.Path]struct{}) - results.Range(func(key tspath.Path, response *response[Resp]) bool { - if response.complete { - requestedProjectTrees[key] = struct{}{} - } - return true - }) + return getResultsIterator(), nil +} - // Load more projects based on default definition found - for _, loadedProject := range s.session.GetSnapshotLoadingProjectTree(ctx, requestedProjectTrees).ProjectCollection.Projects() { - if ctx.Err() != nil { - return ctx.Err() - } +func registerMultiProjectReferenceRequestHandler[Req lsproto.HasTextDocumentPosition, Resp any]( + handlers handlerMap, + info lsproto.RequestInfo[Req, Resp], + fn func(*Server, context.Context, *ls.LanguageService, Req, *ast.Node, []*ls.SymbolAndEntries) (Resp, error), + combineResults func(iter.Seq[Resp]) Resp, +) { + handlers[info.Method] = func(s *Server, ctx context.Context, req *lsproto.RequestMessage) error { + var params Req + // Ignore empty params. + if req.Params != nil { + params = req.Params.(Req) + } + defer s.recover(req) - // Can loop forever without this (enqueue here, dequeue above, repeat) - if !canSearchProject(loadedProject) || loadedProject.GetProgram() == nil { - continue - } + isRename := info.Method == lsproto.MethodTextDocumentRename + resultsIter, err := multiProjectSearchHelper( + s, + ctx, + params.TextDocumentURI(), + params.TextDocumentPosition(), + isRename, + func(ctx context.Context, ls *ls.LanguageService, originalNode *ast.Node, symbolsAndEntries []*ls.SymbolAndEntries) (Resp, error) { + return fn(s, ctx, ls, params, originalNode, symbolsAndEntries) + }, + ) + if err != nil { + return err + } - // Enqueue the project and location for further processing - if loadedProject.HasFile(defaultDefinition.TextDocumentURI().FileName()) { - enqueueItem(projectAndTextDocumentPosition{ - project: loadedProject, - Uri: defaultDefinition.TextDocumentURI(), - Position: defaultDefinition.TextDocumentPosition(), - }) - hasMoreWork = true - } else if sourcePos := defaultDefinition.GetSourcePosition(); sourcePos != nil && loadedProject.HasFile(sourcePos.TextDocumentURI().FileName()) { - enqueueItem(projectAndTextDocumentPosition{ - project: loadedProject, - Uri: sourcePos.TextDocumentURI(), - Position: sourcePos.TextDocumentPosition(), - }) - hasMoreWork = true - } else if generatedPos := defaultDefinition.GetGeneratedPosition(); generatedPos != nil && loadedProject.HasFile(generatedPos.TextDocumentURI().FileName()) { - enqueueItem(projectAndTextDocumentPosition{ - project: loadedProject, - Uri: generatedPos.TextDocumentURI(), - Position: generatedPos.TextDocumentPosition(), - }) - hasMoreWork = true - } - } - } - if !hasMoreWork { - break - } + // Collect results to determine if we need to combine + var results []Resp + for result := range resultsIter { + results = append(results, result) } var resp Resp - if results.Size() > 1 { - resp = combineResults(getResultsIterator()) - } else { - // Single result, return that directly - for value := range getResultsIterator() { - resp = value - break - } + if len(results) > 1 { + resp = combineResults(func(yield func(Resp) bool) { + for _, r := range results { + if !yield(r) { + return + } + } + }) + } else if len(results) == 1 { + resp = results[0] } s.sendResult(req.ID, resp) @@ -1346,15 +1382,83 @@ func (s *Server) handleCodeLens(ctx context.Context, ls *ls.LanguageService, par } func (s *Server) handleCodeLensResolve(ctx context.Context, codeLens *lsproto.CodeLens, reqMsg *lsproto.RequestMessage) (*lsproto.CodeLens, error) { + // For references code lens, use multi-project search to find all references across projects + if codeLens.Data.Kind == lsproto.CodeLensKindReferences { + return s.resolveReferencesCodeLensAcrossProjects(ctx, codeLens) + } + + // For other code lens kinds (like implementations), use the single-project resolution + defer s.recover(reqMsg) ls, err := s.session.GetLanguageService(ctx, codeLens.Data.Uri) if err != nil { return nil, err } - defer s.recover(reqMsg) return ls.ResolveCodeLens(ctx, codeLens, s.initializeParams.InitializationOptions.CodeLensShowLocationsCommandName) } +func (s *Server) resolveReferencesCodeLensAcrossProjects(ctx context.Context, codeLens *lsproto.CodeLens) (*lsproto.CodeLens, error) { + uri := codeLens.Data.Uri + position := codeLens.Range.Start + + // Use the common multi-project search helper + resultsIter, err := multiProjectSearchHelper( + s, + ctx, + uri, + position, + false, // isRename + func(ctx context.Context, ls *ls.LanguageService, originalNode *ast.Node, symbolsAndEntries []*ls.SymbolAndEntries) (lsproto.ReferencesResponse, error) { + return ls.ProvideReferencesFromSymbolAndEntries( + ctx, + &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: position, + Context: &lsproto.ReferenceContext{ + IncludeDeclaration: false, // Don't include the declaration in the references count + }, + }, + originalNode, + symbolsAndEntries, + ) + }, + ) + if err != nil { + return nil, err + } + + // Combine all references from all projects using the same logic as combineReferences + combined := combineReferences(resultsIter) + var locs []lsproto.Location + if combined.Locations != nil { + locs = *combined.Locations + } + + // Build the code lens with the combined references count + locale := locale.FromContext(ctx) + var lensTitle string + if len(locs) == 1 { + lensTitle = diagnostics.X_1_reference.Localize(locale) + } else { + lensTitle = diagnostics.X_0_references.Localize(locale, len(locs)) + } + + cmd := &lsproto.Command{ + Title: lensTitle, + } + if len(locs) > 0 && s.initializeParams.InitializationOptions.CodeLensShowLocationsCommandName != nil { + cmd.Command = *s.initializeParams.InitializationOptions.CodeLensShowLocationsCommandName + cmd.Arguments = &[]any{ + uri, + position, + locs, + } + } + + codeLens.Command = cmd + return codeLens, nil +} + func (s *Server) handlePrepareCallHierarchy( ctx context.Context, languageService *ls.LanguageService, diff --git a/testdata/baselines/reference/fourslash/codeLenses/codeLensOnFunctionAcrossProjects1.baseline.jsonc b/testdata/baselines/reference/fourslash/codeLenses/codeLensOnFunctionAcrossProjects1.baseline.jsonc new file mode 100644 index 0000000000..0fcab7f983 --- /dev/null +++ b/testdata/baselines/reference/fourslash/codeLenses/codeLensOnFunctionAcrossProjects1.baseline.jsonc @@ -0,0 +1,10 @@ +// === Code Lenses === +// === /a/src/foo.ts === +// export function /*CODELENS: 2 references*/aaa() {} +// [|aaa|](); +// + +// === /b/src/bar.ts === +// import * as foo from '../../a/dist/foo.js'; +// foo.[|aaa|](); +// \ No newline at end of file