Clean up FAR aggregation (#48619)

* Clean up FAR and RenameLocations

This change had two goals:

1. Make the code easier to understand, primarily by simplifying the callback structure and minimizing side-effects
2. Improve performance by reducing repeated work, both FAR searches of individual projects and default tsconfig searches

This implementation attempts to preserve the merging order found in the original code (someone less relevant in the present state of using syntactic isDefinition).

* Stop enforcing search and aggregation order

...in preparation for implementing isDefinition explicitly.

Also restore convention of referring to `DocumentPosition`s as "locations".

* Introduce LanguageService.updateIsDefinitionOfReferencedSymbols

...to allow use of the checker when computing isDefinition across projects.

* Update baselines

* Tidy diff

* De-dup simplified results

* Baseline cross-project isDefinition results

* Move de-duping upstream to fix Full output

* Add server baseline test to confirm searches are not repeated

* Manually merge #48758

* Update baseline for newer fix to #48963
This commit is contained in:
Andrew Casey
2022-05-18 17:26:17 -07:00
committed by GitHub
parent e56a067801
commit 12ed01203c
36 changed files with 4233 additions and 228 deletions

View File

@@ -303,7 +303,7 @@ namespace ts.server {
return createSet(({textSpan}) => textSpan.start + 100003 * textSpan.length, documentSpansEqual);
}
function combineProjectOutputForRenameLocations(
function getRenameLocationsWorker(
projects: Projects,
defaultProject: Project,
initialLocation: DocumentPosition,
@@ -311,89 +311,164 @@ namespace ts.server {
findInComments: boolean,
{ providePrefixAndSuffixTextForRename }: UserPreferences
): readonly RenameLocation[] {
const outputs: RenameLocation[] = [];
const seen = createDocumentSpanSet();
combineProjectOutputWorker(
const perProjectResults = getPerProjectReferences(
projects,
defaultProject,
initialLocation,
/*isForRename*/ true,
(project, location, tryAddToTodo) => {
const projectOutputs = project.getLanguageService().findRenameLocations(location.fileName, location.pos, findInStrings, findInComments, providePrefixAndSuffixTextForRename);
if (projectOutputs) {
for (const output of projectOutputs) {
if (!seen.has(output) && !tryAddToTodo(project, documentSpanLocation(output))) {
seen.add(output);
outputs.push(output);
}
}
}
},
(project, position) => project.getLanguageService().findRenameLocations(position.fileName, position.pos, findInStrings, findInComments, providePrefixAndSuffixTextForRename),
(renameLocation, cb) => cb(documentSpanLocation(renameLocation)),
);
return outputs;
// No filtering or dedup'ing is required if there's exactly one project
if (isArray(perProjectResults)) {
return perProjectResults;
}
const results: RenameLocation[] = [];
const seen = createDocumentSpanSet();
perProjectResults.forEach((projectResults, project) => {
for (const result of projectResults) {
// If there's a mapped location, it'll appear in the results for another project
if (!seen.has(result) && !getMappedLocationForProject(documentSpanLocation(result), project)) {
results.push(result);
seen.add(result);
}
}
});
return results;
}
function getDefinitionLocation(defaultProject: Project, initialLocation: DocumentPosition, isForRename: boolean): DocumentPosition | undefined {
const infos = defaultProject.getLanguageService().getDefinitionAtPosition(initialLocation.fileName, initialLocation.pos, /*searchOtherFilesOnly*/ false, isForRename);
const infos = defaultProject.getLanguageService().getDefinitionAtPosition(initialLocation.fileName, initialLocation.pos, /*searchOtherFilesOnly*/ false, /*stopAtAlias*/ isForRename);
const info = infos && firstOrUndefined(infos);
return info && !info.isLocal ? { fileName: info.fileName, pos: info.textSpan.start } : undefined;
}
function combineProjectOutputForReferences(
function getReferencesWorker(
projects: Projects,
defaultProject: Project,
initialLocation: DocumentPosition,
logger: Logger,
): readonly ReferencedSymbol[] {
const outputs: ReferencedSymbol[] = [];
combineProjectOutputWorker(
const perProjectResults = getPerProjectReferences(
projects,
defaultProject,
initialLocation,
/*isForRename*/ false,
(project, location, getMappedLocation) => {
logger.info(`Finding references to ${location.fileName} position ${location.pos} in project ${project.getProjectName()}`);
const projectOutputs = project.getLanguageService().findReferences(location.fileName, location.pos);
if (projectOutputs) {
const clearIsDefinition = projectOutputs[0]?.references[0]?.isDefinition === undefined;
for (const referencedSymbol of projectOutputs) {
const mappedDefinitionFile = getMappedLocation(project, documentSpanLocation(referencedSymbol.definition));
const definition: ReferencedSymbolDefinitionInfo = mappedDefinitionFile === undefined ?
referencedSymbol.definition :
{
...referencedSymbol.definition,
textSpan: createTextSpan(mappedDefinitionFile.pos, referencedSymbol.definition.textSpan.length),
fileName: mappedDefinitionFile.fileName,
contextSpan: getMappedContextSpan(referencedSymbol.definition, project)
};
let symbolToAddTo = find(outputs, o => documentSpansEqual(o.definition, definition));
if (!symbolToAddTo) {
symbolToAddTo = { definition, references: [] };
outputs.push(symbolToAddTo);
}
for (const ref of referencedSymbol.references) {
// If it's in a mapped file, that is added to the todo list by `getMappedLocation`.
if (!contains(symbolToAddTo.references, ref, documentSpansEqual) && !getMappedLocation(project, documentSpanLocation(ref))) {
if (clearIsDefinition) {
delete ref.isDefinition;
}
symbolToAddTo.references.push(ref);
}
}
}
(project, position) => {
logger.info(`Finding references to ${position.fileName} position ${position.pos} in project ${project.getProjectName()}`);
return project.getLanguageService().findReferences(position.fileName, position.pos);
},
(referencedSymbol, cb) => {
cb(documentSpanLocation(referencedSymbol.definition));
for (const ref of referencedSymbol.references) {
cb(documentSpanLocation(ref));
}
},
);
return outputs.filter(o => o.references.length !== 0);
// No re-mapping or isDefinition updatses are required if there's exactly one project
if (isArray(perProjectResults)) {
return perProjectResults;
}
// `isDefinition` is only (definitely) correct in `defaultProject` because we might
// have started the other project searches from related symbols. Propagate the
// correct results to all other projects.
const defaultProjectResults = perProjectResults.get(defaultProject)!;
if (defaultProjectResults[0].references[0].isDefinition === undefined) {
// Clear all isDefinition properties
perProjectResults.forEach(projectResults => {
for (const referencedSymbol of projectResults) {
for (const ref of referencedSymbol.references) {
delete ref.isDefinition;
}
}
});
}
else {
// Correct isDefinition properties from projects other than defaultProject
const knownSymbolSpans = createDocumentSpanSet();
for (const referencedSymbol of defaultProjectResults) {
for (const ref of referencedSymbol.references) {
if (ref.isDefinition) {
knownSymbolSpans.add(ref);
// One is enough - updateIsDefinitionOfReferencedSymbols will fill out the set based on symbols
break;
}
}
}
const updatedProjects = new Set<Project>();
while (true) {
let progress = false;
perProjectResults.forEach((referencedSymbols, project) => {
if (updatedProjects.has(project)) return;
const updated = project.getLanguageService().updateIsDefinitionOfReferencedSymbols(referencedSymbols, knownSymbolSpans);
if (updated) {
updatedProjects.add(project);
progress = true;
}
});
if (!progress) break;
}
perProjectResults.forEach((referencedSymbols, project) => {
if (updatedProjects.has(project)) return;
for (const referencedSymbol of referencedSymbols) {
for (const ref of referencedSymbol.references) {
ref.isDefinition = false;
}
}
});
}
// We need to de-duplicate and aggregate the results by choosing an authoritative version
// of each definition and merging references from all the projects where they appear.
const results: ReferencedSymbol[] = [];
const seenRefs = createDocumentSpanSet(); // It doesn't make sense to have a reference in two definition lists, so we de-dup globally
// TODO: We might end up with a more logical allocation of refs to defs if we pre-sorted the defs by descending ref-count.
// Otherwise, it just ends up attached to the first corresponding def we happen to process. The others may or may not be
// dropped later when we check for defs with ref-count 0.
perProjectResults.forEach((projectResults, project) => {
for (const referencedSymbol of projectResults) {
const mappedDefinitionFile = getMappedLocationForProject(documentSpanLocation(referencedSymbol.definition), project);
const definition: ReferencedSymbolDefinitionInfo = mappedDefinitionFile === undefined ?
referencedSymbol.definition :
{
...referencedSymbol.definition,
textSpan: createTextSpan(mappedDefinitionFile.pos, referencedSymbol.definition.textSpan.length), // Why would the length be the same in the original?
fileName: mappedDefinitionFile.fileName,
contextSpan: getMappedContextSpanForProject(referencedSymbol.definition, project)
};
let symbolToAddTo = find(results, o => documentSpansEqual(o.definition, definition));
if (!symbolToAddTo) {
symbolToAddTo = { definition, references: [] };
results.push(symbolToAddTo);
}
for (const ref of referencedSymbol.references) {
if (!seenRefs.has(ref) && !getMappedLocationForProject(documentSpanLocation(ref), project)) {
seenRefs.add(ref);
symbolToAddTo.references.push(ref);
}
}
}
});
return results.filter(o => o.references.length !== 0);
}
interface ProjectAndLocation<TLocation extends DocumentPosition | undefined> {
interface ProjectAndLocation {
readonly project: Project;
readonly location: TLocation;
readonly location: DocumentPosition;
}
function forEachProjectInProjects(projects: Projects, path: string | undefined, cb: (project: Project, path: string | undefined) => void): void {
@@ -409,53 +484,136 @@ namespace ts.server {
}
}
type CombineProjectOutputCallback<TLocation extends DocumentPosition | undefined> = (
project: Project,
location: TLocation,
getMappedLocation: (project: Project, location: DocumentPosition) => DocumentPosition | undefined,
) => void;
function combineProjectOutputWorker<TLocation extends DocumentPosition | undefined>(
/**
* @param projects Projects initially known to contain {@link initialLocation}
* @param defaultProject The default project containing {@link initialLocation}
* @param initialLocation Where the search operation was triggered
* @param getResultsForPosition This is where you plug in `findReferences`, `renameLocation`, etc
* @param forPositionInResult Given an item returned by {@link getResultsForPosition} enumerate the positions referred to by that result
* @returns In the common case where there's only one project, returns an array of results from {@link getResultsForPosition}.
* If multiple projects were searched - even if they didn't return results - the result will be a map from project to per-project results.
*/
function getPerProjectReferences<TResult>(
projects: Projects,
defaultProject: Project,
initialLocation: TLocation,
initialLocation: DocumentPosition,
isForRename: boolean,
cb: CombineProjectOutputCallback<TLocation>,
): void {
const projectService = defaultProject.projectService;
let toDo: ProjectAndLocation<TLocation>[] | undefined;
const seenProjects = new Set<string>();
forEachProjectInProjects(projects, initialLocation && initialLocation.fileName, (project, path) => {
// TLocation should be either `DocumentPosition` or `undefined`. Since `initialLocation` is `TLocation` this cast should be valid.
const location = (initialLocation ? { fileName: path, pos: initialLocation.pos } : undefined) as TLocation;
toDo = callbackProjectAndLocation(project, location, projectService, toDo, seenProjects, cb);
getResultsForPosition: (project: Project, location: DocumentPosition) => readonly TResult[] | undefined,
forPositionInResult: (result: TResult, cb: (location: DocumentPosition) => void) => void,
): readonly TResult[] | ESMap<Project, readonly TResult[]> {
// If `getResultsForPosition` returns results for a project, they go in here
const resultsMap = new Map<Project, readonly TResult[]>();
const queue: ProjectAndLocation[] = [];
// In order to get accurate isDefinition values for `defaultProject`,
// we need to ensure that it is searched from `initialLocation`.
// The easiest way to do this is to search it first.
queue.push({ project: defaultProject, location: initialLocation });
// This will queue `defaultProject` a second time, but it will be dropped
// as a dup when it is dequeued.
forEachProjectInProjects(projects, initialLocation.fileName, (project, path) => {
const location = { fileName: path!, pos: initialLocation.pos };
queue.push({ project, location });
});
// After initial references are collected, go over every other project and see if it has a reference for the symbol definition.
if (initialLocation) {
const defaultDefinition = getDefinitionLocation(defaultProject, initialLocation, isForRename);
const projectService = defaultProject.projectService;
const cancellationToken = defaultProject.getCancellationToken();
const defaultDefinition = getDefinitionLocation(defaultProject, initialLocation, isForRename);
// Don't call these unless !!defaultDefinition
const getGeneratedDefinition = memoize(() => defaultProject.isSourceOfProjectReferenceRedirect(defaultDefinition!.fileName) ?
defaultDefinition :
defaultProject.getLanguageService().getSourceMapper().tryGetGeneratedPosition(defaultDefinition!));
const getSourceDefinition = memoize(() => defaultProject.isSourceOfProjectReferenceRedirect(defaultDefinition!.fileName) ?
defaultDefinition :
defaultProject.getLanguageService().getSourceMapper().tryGetSourcePosition(defaultDefinition!));
// Track which projects we have already searched so that we don't repeat searches.
// We store the project key, rather than the project, because that's what `loadAncestorProjectTree` wants.
// (For that same reason, we don't use `resultsMap` for this check.)
const searchedProjects = new Set<string>();
onCancellation:
while (queue.length) {
while (queue.length) {
if (cancellationToken.isCancellationRequested()) break onCancellation;
const { project, location } = queue.shift()!;
if (isLocationProjectReferenceRedirect(project, location)) continue;
if (!tryAddToSet(searchedProjects, getProjectKey(project))) continue;
const projectResults = searchPosition(project, location);
if (projectResults) {
resultsMap.set(project, projectResults);
}
}
// At this point, we know about all projects passed in as arguments and any projects in which
// `getResultsForPosition` has returned results. We expand that set to include any projects
// downstream from any of these and then queue new initial-position searches for any new project
// containing `initialLocation`.
if (defaultDefinition) {
const getGeneratedDefinition = memoize(() => defaultProject.isSourceOfProjectReferenceRedirect(defaultDefinition.fileName) ?
defaultDefinition :
defaultProject.getLanguageService().getSourceMapper().tryGetGeneratedPosition(defaultDefinition));
const getSourceDefinition = memoize(() => defaultProject.isSourceOfProjectReferenceRedirect(defaultDefinition.fileName) ?
defaultDefinition :
defaultProject.getLanguageService().getSourceMapper().tryGetSourcePosition(defaultDefinition));
projectService.loadAncestorProjectTree(seenProjects);
// This seems to mean "load all projects downstream from any member of `seenProjects`".
projectService.loadAncestorProjectTree(searchedProjects);
projectService.forEachEnabledProject(project => {
if (!addToSeen(seenProjects, project)) return;
const definition = mapDefinitionInProject(defaultDefinition, project, getGeneratedDefinition, getSourceDefinition);
if (definition) {
toDo = callbackProjectAndLocation<TLocation>(project, definition as TLocation, projectService, toDo, seenProjects, cb);
if (cancellationToken.isCancellationRequested()) return; // There's no mechanism for skipping the remaining projects
if (searchedProjects.has(getProjectKey(project))) return; // Can loop forever without this (enqueue here, dequeue above, repeat)
const location = mapDefinitionInProject(defaultDefinition, project, getGeneratedDefinition, getSourceDefinition);
if (location) {
queue.push({ project, location });
}
});
}
}
while (toDo && toDo.length) {
const next = toDo.pop();
Debug.assertIsDefined(next);
toDo = callbackProjectAndLocation(next.project, next.location, projectService, toDo, seenProjects, cb);
// In the common case where there's only one project, return a simpler result to make
// it easier for the caller to skip post-processing.
if (searchedProjects.size === 1) {
const it = resultsMap.values().next();
Debug.assert(!it.done);
return it.value;
}
return resultsMap;
// May enqueue to otherPositionQueue
function searchPosition(project: Project, location: DocumentPosition): readonly TResult[] | undefined {
const projectResults = getResultsForPosition(project, location);
if (!projectResults) return undefined;
for (const result of projectResults) {
forPositionInResult(result, position => {
// This may trigger a search for a tsconfig, but there are several layers of caching that make it inexpensive
const originalLocation = projectService.getOriginalLocationEnsuringConfiguredProject(project, position);
if (!originalLocation) return;
const originalScriptInfo = projectService.getScriptInfo(originalLocation.fileName)!;
for (const project of originalScriptInfo.containingProjects) {
if (!project.isOrphan()) {
queue.push({ project, location: originalLocation });
}
}
const symlinkedProjectsMap = projectService.getSymlinkedProjects(originalScriptInfo);
if (symlinkedProjectsMap) {
symlinkedProjectsMap.forEach((symlinkedProjects, symlinkedPath) => {
for (const symlinkedProject of symlinkedProjects) {
if (!symlinkedProject.isOrphan()) {
queue.push({ project: symlinkedProject, location: { fileName: symlinkedPath as string, pos: originalLocation.pos } });
}
}
});
}
});
}
return projectResults;
}
}
@@ -492,49 +650,6 @@ namespace ts.server {
sourceFile.resolvedPath !== project.toPath(location.fileName);
}
function callbackProjectAndLocation<TLocation extends DocumentPosition | undefined>(
project: Project,
location: TLocation,
projectService: ProjectService,
toDo: ProjectAndLocation<TLocation>[] | undefined,
seenProjects: Set<string>,
cb: CombineProjectOutputCallback<TLocation>,
): ProjectAndLocation<TLocation>[] | undefined {
if (project.getCancellationToken().isCancellationRequested()) return undefined; // Skip rest of toDo if cancelled
// If this is not the file we were actually looking, return rest of the toDo
if (isLocationProjectReferenceRedirect(project, location)) return toDo;
cb(project, location, (innerProject, location) => {
addToSeen(seenProjects, project);
const originalLocation = projectService.getOriginalLocationEnsuringConfiguredProject(innerProject, location);
if (!originalLocation) return undefined;
const originalScriptInfo = projectService.getScriptInfo(originalLocation.fileName)!;
toDo = toDo || [];
for (const project of originalScriptInfo.containingProjects) {
addToTodo(project, originalLocation as TLocation, toDo, seenProjects);
}
const symlinkedProjectsMap = projectService.getSymlinkedProjects(originalScriptInfo);
if (symlinkedProjectsMap) {
symlinkedProjectsMap.forEach((symlinkedProjects, symlinkedPath) => {
for (const symlinkedProject of symlinkedProjects) {
addToTodo(symlinkedProject, { fileName: symlinkedPath as string, pos: originalLocation.pos } as TLocation, toDo!, seenProjects);
}
});
}
return originalLocation === location ? undefined : originalLocation;
});
return toDo;
}
function addToTodo<TLocation extends DocumentPosition | undefined>(project: Project, location: TLocation, toDo: Push<ProjectAndLocation<TLocation>>, seenProjects: Set<string>): void {
if (!project.isOrphan() && addToSeen(seenProjects, project)) toDo.push({ project, location });
}
function addToSeen(seenProjects: Set<string>, project: Project) {
return tryAddToSet(seenProjects, getProjectKey(project));
}
function getProjectKey(project: Project) {
return isConfiguredProject(project) ? project.canonicalConfigFilePath : project.getProjectName();
}
@@ -543,39 +658,16 @@ namespace ts.server {
return { fileName, pos: textSpan.start };
}
function getMappedLocation(location: DocumentPosition, project: Project): DocumentPosition | undefined {
const mapsTo = project.getSourceMapper().tryGetSourcePosition(location);
return mapsTo && project.projectService.fileExists(toNormalizedPath(mapsTo.fileName)) ? mapsTo : undefined;
function getMappedLocationForProject(location: DocumentPosition, project: Project): DocumentPosition | undefined {
return getMappedLocation(location, project.getSourceMapper(), p => project.projectService.fileExists(p as NormalizedPath));
}
function getMappedDocumentSpan(documentSpan: DocumentSpan, project: Project): DocumentSpan | undefined {
const newPosition = getMappedLocation(documentSpanLocation(documentSpan), project);
if (!newPosition) return undefined;
return {
fileName: newPosition.fileName,
textSpan: {
start: newPosition.pos,
length: documentSpan.textSpan.length
},
originalFileName: documentSpan.fileName,
originalTextSpan: documentSpan.textSpan,
contextSpan: getMappedContextSpan(documentSpan, project),
originalContextSpan: documentSpan.contextSpan
};
function getMappedDocumentSpanForProject(documentSpan: DocumentSpan, project: Project): DocumentSpan | undefined {
return getMappedDocumentSpan(documentSpan, project.getSourceMapper(), p => project.projectService.fileExists(p as NormalizedPath));
}
function getMappedContextSpan(documentSpan: DocumentSpan, project: Project): TextSpan | undefined {
const contextSpanStart = documentSpan.contextSpan && getMappedLocation(
{ fileName: documentSpan.fileName, pos: documentSpan.contextSpan.start },
project
);
const contextSpanEnd = documentSpan.contextSpan && getMappedLocation(
{ fileName: documentSpan.fileName, pos: documentSpan.contextSpan.start + documentSpan.contextSpan.length },
project
);
return contextSpanStart && contextSpanEnd ?
{ start: contextSpanStart.pos, length: contextSpanEnd.pos - contextSpanStart.pos } :
undefined;
function getMappedContextSpanForProject(documentSpan: DocumentSpan, project: Project): TextSpan | undefined {
return getMappedContextSpan(documentSpan, project.getSourceMapper(), p => project.projectService.fileExists(p as NormalizedPath));
}
const invalidPartialSemanticModeCommands: readonly CommandNames[] = [
@@ -1189,7 +1281,7 @@ namespace ts.server {
private mapDefinitionInfoLocations(definitions: readonly DefinitionInfo[], project: Project): readonly DefinitionInfo[] {
return definitions.map((info): DefinitionInfo => {
const newDocumentSpan = getMappedDocumentSpan(info, project);
const newDocumentSpan = getMappedDocumentSpanForProject(info, project);
return !newDocumentSpan ? info : {
...newDocumentSpan,
containerKind: info.containerKind,
@@ -1484,7 +1576,7 @@ namespace ts.server {
private mapImplementationLocations(implementations: readonly ImplementationLocation[], project: Project): readonly ImplementationLocation[] {
return implementations.map((info): ImplementationLocation => {
const newDocumentSpan = getMappedDocumentSpan(info, project);
const newDocumentSpan = getMappedDocumentSpanForProject(info, project);
return !newDocumentSpan ? info : {
...newDocumentSpan,
kind: info.kind,
@@ -1665,7 +1757,7 @@ namespace ts.server {
if (!renameInfo.canRename) return simplifiedResult ? { info: renameInfo, locs: [] } : [];
const locations = combineProjectOutputForRenameLocations(
const locations = getRenameLocationsWorker(
projects,
defaultProject,
{ fileName: args.file, pos: position },
@@ -1703,7 +1795,7 @@ namespace ts.server {
const file = toNormalizedPath(args.file);
const projects = this.getProjects(args);
const position = this.getPositionInFile(args, file);
const references = combineProjectOutputForReferences(
const references = getReferencesWorker(
projects,
this.getDefaultProject(args),
{ fileName: args.file, pos: position },
@@ -2298,7 +2390,8 @@ namespace ts.server {
const seenItems = new Map<string, NavigateToItem[]>(); // name to items with that name
if (!args.file && !projectFileName) {
// VS Code's `Go to symbol in workspaces` sends request like this
// VS Code's `Go to symbol in workspaces` sends request like this by default.
// There's a setting to have it send a file name (reverting to older behavior).
// TODO (https://github.com/microsoft/TypeScript/issues/47839)
// This appears to have been intended to search all projects but, in practice, it seems to only search
@@ -2323,7 +2416,7 @@ namespace ts.server {
// Mutates `outputs`
function addItemsForProject(project: Project) {
const projectItems = project.getLanguageService().getNavigateToItems(searchValue, maxResultCount, /*filename*/ undefined, /*excludeDts*/ project.isNonTsProject());
const unseenItems = filter(projectItems, item => tryAddSeenItem(item) && !getMappedLocation(documentSpanLocation(item), project));
const unseenItems = filter(projectItems, item => tryAddSeenItem(item) && !getMappedLocationForProject(documentSpanLocation(item), project));
if (unseenItems.length) {
outputs.push({ project, navigateToItems: unseenItems });
}