diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 841dba8a528..aa4bd61edce 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -524,7 +524,12 @@ namespace ts { process.exit(exitCode); }, realpath(path: string): string { - return _fs.realpathSync(path); + try { + return _fs.realpathSync(path); + } + catch { + return path; + } }, debugMode: some(process.execArgv, arg => /^--(inspect|debug)(-brk)?(=\d+)?$/i.test(arg)), tryEnableSourceMapsForHost() { diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 2811584bf1b..e54988e223d 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -6604,6 +6604,7 @@ namespace ts.projectSystem { const host = createServerHost(files); const session = createSession(host); const projectService = session.getProjectService(); + debugger; session.executeCommandSeq({ command: protocol.CommandTypes.Open, arguments: { @@ -6637,6 +6638,7 @@ namespace ts.projectSystem { assert.isDefined(projectService.configuredProjects.get(aTsconfig.path)); assert.isDefined(projectService.configuredProjects.get(bTsconfig.path)); + debugger; verifyRenameResponse(session.executeCommandSeq({ command: protocol.CommandTypes.Rename, arguments: { @@ -6650,20 +6652,25 @@ namespace ts.projectSystem { function verifyRenameResponse({ info, locs }: protocol.RenameResponseBody) { assert.isTrue(info.canRename); - assert.equal(locs.length, 2); // Currently 2 but needs to be 4 - assert.deepEqual(locs[0], { - file: aFile.path, - locs: [ - { start: { line: 1, offset: 39 }, end: { line: 1, offset: 40 } }, - { start: { line: 1, offset: 9 }, end: { line: 1, offset: 10 } } - ] - }); - assert.deepEqual(locs[1], { - file: aFc, - locs: [ - { start: { line: 1, offset: 14 }, end: { line: 1, offset: 15 } } - ] - }); + assert.equal(locs.length, 4); + verifyLocations(0, aFile.path, aFc); + verifyLocations(2, bFile.path, bFc); + + function verifyLocations(locStartIndex: number, firstFile: string, secondFile: string) { + assert.deepEqual(locs[locStartIndex], { + file: firstFile, + locs: [ + { start: { line: 1, offset: 39 }, end: { line: 1, offset: 40 } }, + { start: { line: 1, offset: 9 }, end: { line: 1, offset: 10 } } + ] + }); + assert.deepEqual(locs[locStartIndex + 1], { + file: secondFile, + locs: [ + { start: { line: 1, offset: 14 }, end: { line: 1, offset: 15 } } + ] + }); + } } }); }); diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index 5b326789247..90129e8e0f5 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -88,7 +88,7 @@ interface Array {}` } interface SymLink extends FSEntry { - symLink: Path; + symLink: string; } function isFolder(s: FSEntry): s is Folder { @@ -526,7 +526,7 @@ interface Array {}` return { path: this.toPath(fullPath), fullPath, - symLink: this.toPath(getNormalizedAbsolutePath(fileOrDirectory.symLink, getDirectoryPath(fullPath))) + symLink: getNormalizedAbsolutePath(fileOrDirectory.symLink, getDirectoryPath(fullPath)) }; } @@ -539,17 +539,13 @@ interface Array {}` }; } - private isFile(fsEntry: FSEntry) { - return !!this.getRealFile(fsEntry.path, fsEntry); - } - - private getRealFile(path: Path, fsEntry = this.fs.get(path)): File | undefined { - if (isFile(fsEntry)) { + private getRealFsEntry(isFsEntry: (fsEntry: FSEntry) => fsEntry is T, path: Path, fsEntry = this.fs.get(path)): T | undefined { + if (isFsEntry(fsEntry)) { return fsEntry; } if (isSymLink(fsEntry)) { - return this.getRealFile(fsEntry.symLink); + return this.getRealFsEntry(isFsEntry, this.toPath(fsEntry.symLink)); } if (fsEntry) { @@ -557,42 +553,28 @@ interface Array {}` return undefined; } - const dir = getDirectoryPath(path); - const dirEntry = this.getRealFolder(dir); - if (dirEntry && dirEntry.path !== dir) { - return this.getRealFile(combinePaths(dirEntry.path, getBaseFileName(path)) as Path); + const realpath = this.realpath(path); + if (path !== realpath) { + return this.getRealFsEntry(isFsEntry, realpath as Path); } + return undefined; } + private isFile(fsEntry: FSEntry) { + return !!this.getRealFile(fsEntry.path, fsEntry); + } + + private getRealFile(path: Path, fsEntry?: FSEntry): File | undefined { + return this.getRealFsEntry(isFile, path, fsEntry); + } + private isFolder(fsEntry: FSEntry) { return !!this.getRealFolder(fsEntry.path, fsEntry); } private getRealFolder(path: Path, fsEntry = this.fs.get(path)): Folder | undefined { - if (isFolder(fsEntry)) { - return fsEntry; - } - - if (isSymLink(fsEntry)) { - return this.getRealFolder(fsEntry.symLink); - } - - if (fsEntry) { - // This fs entry is something else - return undefined; - } - - const baseName = getBaseFileName(path); - const dir = getDirectoryPath(path); - if (dir !== baseName) { - const dirEntry = this.getRealFolder(dir); - if (dirEntry && dirEntry.path !== dir) { - return this.getRealFolder(combinePaths(dirEntry.path, baseName) as Path); - } - } - - return undefined; + return this.getRealFsEntry(isFolder, path, fsEntry); } fileExists(s: string) { @@ -765,16 +747,21 @@ interface Array {}` clear(this.output); } - realpath(s: string) { - while (true) { - const fsEntry = this.fs.get(this.toFullPath(s)); - if (isSymLink(fsEntry)) { - s = fsEntry.symLink; - } - else { - return s; - } + realpath(s: string): string { + const fullPath = this.toNormalizedAbsolutePath(s); + const path = this.toPath(fullPath); + if (getDirectoryPath(path) === path) { + // Root + return s; } + const dirFullPath = this.realpath(getDirectoryPath(fullPath)); + const realFullPath = combinePaths(dirFullPath, getBaseFileName(fullPath)); + const fsEntry = this.fs.get(this.toPath(realFullPath)); + if (isSymLink(fsEntry)) { + return this.realpath(fsEntry.symLink); + } + + return realFullPath; } readonly existMessage = "System Exit"; diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 664938a4cdc..864383d1052 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -198,16 +198,6 @@ namespace ts.server { } } - /** - * This helper function processes a list of projects and return the concatenated, sortd and deduplicated output of processing each project. - */ - export function combineProjectOutput(projects: ReadonlyArray, action: (project: Project) => ReadonlyArray, comparer?: (a: T, b: T) => number, areEqual?: (a: T, b: T) => boolean) { - const outputs = flatMap(projects, action); - return comparer - ? sortAndDeduplicate(outputs, comparer, areEqual) - : deduplicate(outputs, areEqual); - } - export interface HostConfiguration { formatCodeOptions: FormatCodeSettings; hostInfo: string; @@ -335,6 +325,11 @@ namespace ts.server { * Container of all known scripts */ private readonly filenameToScriptInfo = createMap(); + /** + * Map to the real path of the infos + */ + /* @internal */ + readonly realpathToScriptInfos: MultiMap | undefined; /** * maps external project file name to list of config files that were the part of this project */ @@ -427,7 +422,9 @@ namespace ts.server { this.typesMapLocation = (opts.typesMapLocation === undefined) ? combinePaths(this.getExecutingFilePath(), "../typesMap.json") : opts.typesMapLocation; Debug.assert(!!this.host.createHash, "'ServerHost.createHash' is required for ProjectService"); - + if (this.host.realpath) { + this.realpathToScriptInfos = createMultiMap(); + } this.currentDirectory = this.host.getCurrentDirectory(); this.toCanonicalFileName = createGetCanonicalFileName(this.host.useCaseSensitiveFileNames); this.throttledOperations = new ThrottledOperations(this.host, this.logger); @@ -768,7 +765,7 @@ namespace ts.server { if (info.containingProjects.length === 0) { // Orphan script info, remove it as we can always reload it on next open file request this.stopWatchingScriptInfo(info); - this.filenameToScriptInfo.delete(info.path); + this.deleteScriptInfo(info); } else { // file has been changed which might affect the set of referenced files in projects that include @@ -785,7 +782,7 @@ namespace ts.server { // TODO: handle isOpen = true case if (!info.isScriptOpen()) { - this.filenameToScriptInfo.delete(info.path); + this.deleteScriptInfo(info); // capture list of projects since detachAllProjects will wipe out original list const containingProjects = info.containingProjects.slice(); @@ -1019,11 +1016,19 @@ namespace ts.server { if (!info.isScriptOpen() && info.isOrphan()) { // if there are not projects that include this script info - delete it this.stopWatchingScriptInfo(info); - this.filenameToScriptInfo.delete(info.path); + this.deleteScriptInfo(info); } }); } + private deleteScriptInfo(info: ScriptInfo) { + this.filenameToScriptInfo.delete(info.path); + const realpath = info.getRealpathIfDifferent(); + if (realpath) { + this.realpathToScriptInfos.remove(realpath, info); + } + } + private configFileExists(configFileName: NormalizedPath, canonicalConfigFilePath: string, info: ScriptInfo) { let configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); if (configFileExistenceInfo) { @@ -1736,6 +1741,43 @@ namespace ts.server { return this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName)); } + /** + * Returns the projects that contain script info through SymLink + * Note that this does not return projects in info.containingProjects + */ + /*@internal*/ + getSymlinkedProjects(info: ScriptInfo): MultiMap | undefined { + let projects: MultiMap | undefined; + if (this.realpathToScriptInfos) { + const realpath = info.getRealpathIfDifferent(); + if (realpath) { + forEach(this.realpathToScriptInfos.get(realpath), combineProjects); + } + forEach(this.realpathToScriptInfos.get(info.path), combineProjects); + } + + return projects; + + function combineProjects(toAddInfo: ScriptInfo) { + if (toAddInfo !== info) { + for (const project of toAddInfo.containingProjects) { + // Add the projects only if they can use symLink targets and not already in the list + if (project.languageServiceEnabled && + !project.getCompilerOptions().preserveSymlinks && + !contains(info.containingProjects, project)) { + if (!projects) { + projects = createMultiMap(); + projects.add(toAddInfo.path, project); + } + else if (!forEachEntry(projects, (projs, path) => path === toAddInfo.path ? false : contains(projs, project))) { + projects.add(toAddInfo.path, project); + } + } + } + } + } + } + private watchClosedScriptInfo(info: ScriptInfo) { Debug.assert(!info.fileWatcher); // do not watch files with mixed content - server doesn't know how to interpret it diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index f800a1117d0..bf3cc05c0c8 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -213,6 +213,10 @@ namespace ts.server { /*@internal*/ readonly isDynamic: boolean; + /*@internal*/ + /** Set to real path if path is different from info.path */ + private realpath: Path | undefined; + constructor( private readonly host: ServerHost, readonly fileName: NormalizedPath, @@ -224,6 +228,7 @@ namespace ts.server { this.textStorage = new TextStorage(host, fileName); if (hasMixedContent || this.isDynamic) { this.textStorage.reload(""); + this.realpath = this.path; } this.scriptKind = scriptKind ? scriptKind @@ -264,6 +269,30 @@ namespace ts.server { return this.textStorage.getSnapshot(); } + private ensureRealPath() { + if (this.realpath === undefined) { + // Default is just the path + this.realpath = this.path; + if (this.host.realpath) { + Debug.assert(!!this.containingProjects.length); + const project = this.containingProjects[0]; + const realpath = this.host.realpath(this.path); + if (realpath) { + this.realpath = project.toPath(realpath); + // If it is different from this.path, add to the map + if (this.realpath !== this.path) { + project.projectService.realpathToScriptInfos.add(this.realpath, this); + } + } + } + } + } + + /*@internal*/ + getRealpathIfDifferent(): Path | undefined { + return this.realpath && this.realpath !== this.path ? this.realpath : undefined; + } + getFormatCodeSettings() { return this.formatCodeSettings; } @@ -272,6 +301,9 @@ namespace ts.server { const isNew = !this.isAttached(project); if (isNew) { this.containingProjects.push(project); + if (!project.getCompilerOptions().preserveSymlinks) { + this.ensureRealPath(); + } } return isNew; } diff --git a/src/server/session.ts b/src/server/session.ts index 693d1651c91..6b55b540577 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -255,6 +255,32 @@ namespace ts.server { }; } + type Projects = ReadonlyArray | { + projects: ReadonlyArray; + symLinkedProjects: MultiMap; + }; + + function isProjectsArray(projects: Projects): projects is ReadonlyArray { + return !!(>projects).length; + } + + /** + * This helper function processes a list of projects and return the concatenated, sortd and deduplicated output of processing each project. + */ + function combineProjectOutput(defaultValue: T, getValue: (path: Path) => T, projects: Projects, action: (project: Project, value: T) => ReadonlyArray | U | undefined, comparer?: (a: U, b: U) => number, areEqual?: (a: U, b: U) => boolean) { + const outputs = flatMap(isProjectsArray(projects) ? projects : projects.projects, project => action(project, defaultValue)); + if (!isProjectsArray(projects) && projects.symLinkedProjects) { + projects.symLinkedProjects.forEach((projects, path) => { + const value = getValue(path as Path); + outputs.push(...flatMap(projects, project => action(project, value))); + }); + } + + return comparer + ? sortAndDeduplicate(outputs, comparer, areEqual) + : deduplicate(outputs, areEqual); + } + export interface SessionOptions { host: ServerHost; cancellationToken: ServerCancellationToken; @@ -789,8 +815,9 @@ namespace ts.server { return project.getLanguageService().getRenameInfo(file, position); } - private getProjects(args: protocol.FileRequestArgs) { - let projects: Project[]; + private getProjects(args: protocol.FileRequestArgs): Projects { + let projects: ReadonlyArray; + let symLinkedProjects: MultiMap | undefined; if (args.projectFileName) { const project = this.getProject(args.projectFileName); if (project) { @@ -800,13 +827,14 @@ namespace ts.server { else { const scriptInfo = this.projectService.getScriptInfo(args.file); projects = scriptInfo.containingProjects; + symLinkedProjects = this.projectService.getSymlinkedProjects(scriptInfo); } // filter handles case when 'projects' is undefined projects = filter(projects, p => p.languageServiceEnabled); - if (!projects || !projects.length) { + if ((!projects || !projects.length) && !symLinkedProjects) { return Errors.ThrowNoProject(); } - return projects; + return symLinkedProjects ? { projects, symLinkedProjects } : projects; } private getDefaultProject(args: protocol.FileRequestArgs) { @@ -841,8 +869,10 @@ namespace ts.server { } const fileSpans = combineProjectOutput( + file, + path => this.projectService.getScriptInfoForPath(path).fileName, projects, - (project: Project) => { + (project, file) => { const renameLocations = project.getLanguageService().findRenameLocations(file, position, args.findInStrings, args.findInComments); if (!renameLocations) { return emptyArray; @@ -881,8 +911,10 @@ namespace ts.server { } else { return combineProjectOutput( + file, + path => this.projectService.getScriptInfoForPath(path).fileName, projects, - p => p.getLanguageService().findRenameLocations(file, position, args.findInStrings, args.findInComments), + (p, file) => p.getLanguageService().findRenameLocations(file, position, args.findInStrings, args.findInComments), /*comparer*/ undefined, renameLocationIsEqualTo ); @@ -938,9 +970,11 @@ namespace ts.server { const nameSpan = nameInfo.textSpan; const nameColStart = scriptInfo.positionToLineOffset(nameSpan.start).offset; const nameText = scriptInfo.getSnapshot().getText(nameSpan.start, textSpanEnd(nameSpan)); - const refs = combineProjectOutput( + const refs = combineProjectOutput( + file, + path => this.projectService.getScriptInfoForPath(path).fileName, projects, - (project: Project) => { + (project, file) => { const references = project.getLanguageService().getReferencesAtPosition(file, position); if (!references) { return emptyArray; @@ -974,8 +1008,10 @@ namespace ts.server { } else { return combineProjectOutput( + file, + path => this.projectService.getScriptInfoForPath(path).fileName, projects, - project => project.getLanguageService().findReferences(file, position), + (project, file) => project.getLanguageService().findReferences(file, position), /*comparer*/ undefined, equateValues ); @@ -1240,20 +1276,25 @@ namespace ts.server { return emptyArray; } - const result: protocol.CompileOnSaveAffectedFileListSingleProject[] = []; - // if specified a project, we only return affected file list in this project - const projectsToSearch = args.projectFileName ? [this.projectService.findProject(args.projectFileName)] : info.containingProjects; - for (const project of projectsToSearch) { - if (project.compileOnSaveEnabled && project.languageServiceEnabled && !project.getCompilationSettings().noEmit) { - result.push({ - projectFileName: project.getProjectName(), - fileNames: project.getCompileOnSaveAffectedFileList(info), - projectUsesOutFile: !!project.getCompilationSettings().outFile || !!project.getCompilationSettings().out - }); + const projects = args.projectFileName ? [this.projectService.findProject(args.projectFileName)] : info.containingProjects; + const symLinkedProjects = !args.projectFileName && this.projectService.getSymlinkedProjects(info); + return combineProjectOutput( + info, + path => this.projectService.getScriptInfoForPath(path), + symLinkedProjects ? { projects, symLinkedProjects } : projects, + (project, info) => { + let result: protocol.CompileOnSaveAffectedFileListSingleProject; + if (project.compileOnSaveEnabled && project.languageServiceEnabled && !project.getCompilationSettings().noEmit) { + result = { + projectFileName: project.getProjectName(), + fileNames: project.getCompileOnSaveAffectedFileList(info), + projectUsesOutFile: !!project.getCompilationSettings().outFile || !!project.getCompilationSettings().out + }; + } + return result; } - } - return result; + ); } private emitFile(args: protocol.CompileOnSaveEmitFileRequestArgs) { @@ -1406,8 +1447,14 @@ namespace ts.server { const fileName = args.currentFileOnly ? args.file && normalizeSlashes(args.file) : undefined; if (simplifiedResult) { return combineProjectOutput( + fileName, + () => undefined, projects, - project => { + (project, file) => { + if (fileName && !file) { + return undefined; + } + const navItems = project.getLanguageService().getNavigateToItems(args.searchValue, args.maxResultCount, fileName, /*excludeDts*/ project.isNonTsProject()); if (!navItems) { return emptyArray; @@ -1443,8 +1490,15 @@ namespace ts.server { } else { return combineProjectOutput( + fileName, + () => undefined, projects, - project => project.getLanguageService().getNavigateToItems(args.searchValue, args.maxResultCount, fileName, /*excludeDts*/ project.isNonTsProject()), + (project, file) => { + if (fileName && !file) { + return undefined; + } + return project.getLanguageService().getNavigateToItems(args.searchValue, args.maxResultCount, fileName, /*excludeDts*/ project.isNonTsProject()); + }, /*comparer*/ undefined, navigateToItemIsEqualTo); } diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 3eabea4435a..ef26cbc743b 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -7151,6 +7151,7 @@ declare namespace ts.server { open(newText: string): void; close(fileExists?: boolean): void; getSnapshot(): IScriptSnapshot; + private ensureRealPath(); getFormatCodeSettings(): FormatCodeSettings; attachToProject(project: Project): boolean; isAttached(project: Project): boolean; @@ -7523,10 +7524,6 @@ declare namespace ts.server { function convertCompilerOptions(protocolOptions: protocol.ExternalProjectCompilerOptions): CompilerOptions & protocol.CompileOnSaveMixin; function tryConvertScriptKindName(scriptKindName: protocol.ScriptKindName | ScriptKind): ScriptKind; function convertScriptKindName(scriptKindName: protocol.ScriptKindName): ScriptKind.Unknown | ScriptKind.JS | ScriptKind.JSX | ScriptKind.TS | ScriptKind.TSX; - /** - * This helper function processes a list of projects and return the concatenated, sortd and deduplicated output of processing each project. - */ - function combineProjectOutput(projects: ReadonlyArray, action: (project: Project) => ReadonlyArray, comparer?: (a: T, b: T) => number, areEqual?: (a: T, b: T) => boolean): T[]; interface HostConfiguration { formatCodeOptions: FormatCodeSettings; hostInfo: string; @@ -7662,6 +7659,7 @@ declare namespace ts.server { */ private closeOpenFile(info); private deleteOrphanScriptInfoNotInAnyProject(); + private deleteScriptInfo(info); private configFileExists(configFileName, canonicalConfigFilePath, info); private setConfigFileExistenceByNewConfiguredProject(project); /**