Skip to content

Commit dbef230

Browse files
authored
This handles when packages are symbol links in mono repo like scenarios to use source files instead of output d.ts from project reference (microsoft#34743)
* Fix incorrect outDir usage instead of out * Handle symlinks of packages in mono repo like packages Fixes microsoft#34723 * Added clarified comment
1 parent 554bd24 commit dbef230

File tree

4 files changed

+226
-18
lines changed

4 files changed

+226
-18
lines changed

src/compiler/program.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -2271,7 +2271,19 @@ namespace ts {
22712271
// Get source file from normalized fileName
22722272
function findSourceFile(fileName: string, path: Path, isDefaultLib: boolean, ignoreNoDefaultLib: boolean, refFile: RefFile | undefined, packageId: PackageId | undefined): SourceFile | undefined {
22732273
if (useSourceOfProjectReferenceRedirect) {
2274-
const source = getSourceOfProjectReferenceRedirect(fileName);
2274+
let source = getSourceOfProjectReferenceRedirect(fileName);
2275+
// If preserveSymlinks is true, module resolution wont jump the symlink
2276+
// but the resolved real path may be the .d.ts from project reference
2277+
// Note:: Currently we try the real path only if the
2278+
// file is from node_modules to avoid having to run real path on all file paths
2279+
if (!source &&
2280+
host.realpath &&
2281+
options.preserveSymlinks &&
2282+
isDeclarationFileName(fileName) &&
2283+
stringContains(fileName, nodeModulesPathPart)) {
2284+
const realPath = host.realpath(fileName);
2285+
if (realPath !== fileName) source = getSourceOfProjectReferenceRedirect(realPath);
2286+
}
22752287
if (source) {
22762288
const file = isString(source) ?
22772289
findSourceFile(source, toPath(source), isDefaultLib, ignoreNoDefaultLib, refFile, packageId) :

src/server/project.ts

+112-16
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ namespace ts.server {
258258
private compilerOptions: CompilerOptions,
259259
public compileOnSaveEnabled: boolean,
260260
directoryStructureHost: DirectoryStructureHost,
261-
currentDirectory: string | undefined) {
261+
currentDirectory: string | undefined,
262+
customRealpath?: (s: string) => string) {
262263
this.directoryStructureHost = directoryStructureHost;
263264
this.currentDirectory = this.projectService.getNormalizedAbsolutePath(currentDirectory || "");
264265
this.getCanonicalFileName = this.projectService.toCanonicalFileName;
@@ -286,7 +287,7 @@ namespace ts.server {
286287
}
287288

288289
if (host.realpath) {
289-
this.realpath = path => host.realpath!(path);
290+
this.realpath = customRealpath || (path => host.realpath!(path));
290291
}
291292

292293
// Use the current directory as resolution root only if the project created using current directory string
@@ -1660,6 +1661,12 @@ namespace ts.server {
16601661
}
16611662
}
16621663

1664+
/*@internal*/
1665+
interface SymlinkedDirectory {
1666+
real: string;
1667+
realPath: Path;
1668+
}
1669+
16631670
/**
16641671
* If a file is opened, the server will look for a tsconfig (or jsconfig)
16651672
* and if successfull create a ConfiguredProject for it.
@@ -1673,6 +1680,8 @@ namespace ts.server {
16731680
readonly canonicalConfigFilePath: NormalizedPath;
16741681
private projectReferenceCallbacks: ResolvedProjectReferenceCallbacks | undefined;
16751682
private mapOfDeclarationDirectories: Map<true> | undefined;
1683+
private symlinkedDirectories: Map<SymlinkedDirectory | false> | undefined;
1684+
private symlinkedFiles: Map<string> | undefined;
16761685

16771686
/* @internal */
16781687
pendingReload: ConfigFileProgramReloadLevel | undefined;
@@ -1714,7 +1723,9 @@ namespace ts.server {
17141723
/*compilerOptions*/ {},
17151724
/*compileOnSaveEnabled*/ false,
17161725
cachedDirectoryStructureHost,
1717-
getDirectoryPath(configFileName));
1726+
getDirectoryPath(configFileName),
1727+
projectService.host.realpath && (s => this.getRealpath(s))
1728+
);
17181729
this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName));
17191730
}
17201731

@@ -1727,18 +1738,34 @@ namespace ts.server {
17271738
useSourceOfProjectReferenceRedirect = () => !!this.languageServiceEnabled &&
17281739
!this.getCompilerOptions().disableSourceOfProjectReferenceRedirect;
17291740

1741+
private fileExistsIfProjectReferenceDts(file: string) {
1742+
const source = this.projectReferenceCallbacks!.getSourceOfProjectReferenceRedirect(file);
1743+
return source !== undefined ?
1744+
isString(source) ? super.fileExists(source) : true :
1745+
undefined;
1746+
}
1747+
17301748
/**
17311749
* This implementation of fileExists checks if the file being requested is
17321750
* .d.ts file for the referenced Project.
17331751
* If it is it returns true irrespective of whether that file exists on host
17341752
*/
17351753
fileExists(file: string): boolean {
1754+
if (super.fileExists(file)) return true;
1755+
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return false;
1756+
if (!isDeclarationFileName(file)) return false;
1757+
17361758
// Project references go to source file instead of .d.ts file
1737-
if (this.useSourceOfProjectReferenceRedirect() && this.projectReferenceCallbacks) {
1738-
const source = this.projectReferenceCallbacks.getSourceOfProjectReferenceRedirect(file);
1739-
if (source) return isString(source) ? super.fileExists(source) : true;
1740-
}
1741-
return super.fileExists(file);
1759+
return this.fileOrDirectoryExistsUsingSource(file, /*isFile*/ true);
1760+
}
1761+
1762+
private directoryExistsIfProjectReferenceDeclDir(dir: string) {
1763+
const dirPath = this.toPath(dir);
1764+
const dirPathWithTrailingDirectorySeparator = `${dirPath}${directorySeparator}`;
1765+
return forEachKey(
1766+
this.mapOfDeclarationDirectories!,
1767+
declDirPath => dirPath === declDirPath || startsWith(declDirPath, dirPathWithTrailingDirectorySeparator)
1768+
);
17421769
}
17431770

17441771
/**
@@ -1747,14 +1774,17 @@ namespace ts.server {
17471774
* If it is it returns true irrespective of whether that directory exists on host
17481775
*/
17491776
directoryExists(path: string): boolean {
1750-
if (super.directoryExists(path)) return true;
1777+
if (super.directoryExists(path)) {
1778+
this.handleDirectoryCouldBeSymlink(path);
1779+
return true;
1780+
}
17511781
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return false;
17521782

17531783
if (!this.mapOfDeclarationDirectories) {
17541784
this.mapOfDeclarationDirectories = createMap();
17551785
this.projectReferenceCallbacks.forEachResolvedProjectReference(ref => {
17561786
if (!ref) return;
1757-
const out = ref.commandLine.options.outFile || ref.commandLine.options.outDir;
1787+
const out = ref.commandLine.options.outFile || ref.commandLine.options.out;
17581788
if (out) {
17591789
this.mapOfDeclarationDirectories!.set(getDirectoryPath(this.toPath(out)), true);
17601790
}
@@ -1767,12 +1797,74 @@ namespace ts.server {
17671797
}
17681798
});
17691799
}
1770-
const dirPath = this.toPath(path);
1771-
const dirPathWithTrailingDirectorySeparator = `${dirPath}${directorySeparator}`;
1772-
return !!forEachKey(
1773-
this.mapOfDeclarationDirectories,
1774-
declDirPath => dirPath === declDirPath || startsWith(declDirPath, dirPathWithTrailingDirectorySeparator)
1775-
);
1800+
1801+
return this.fileOrDirectoryExistsUsingSource(path, /*isFile*/ false);
1802+
}
1803+
1804+
private realpathIfSymlinkedProjectReferenceDts(s: string): string | undefined {
1805+
return this.symlinkedFiles && this.symlinkedFiles.get(this.toPath(s));
1806+
}
1807+
1808+
private getRealpath(s: string): string {
1809+
return this.realpathIfSymlinkedProjectReferenceDts(s) ||
1810+
this.projectService.host.realpath!(s);
1811+
}
1812+
1813+
private handleDirectoryCouldBeSymlink(directory: string) {
1814+
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return;
1815+
1816+
// Because we already watch node_modules, handle symlinks in there
1817+
if (!this.realpath || !stringContains(directory, nodeModulesPathPart)) return;
1818+
if (!this.symlinkedDirectories) this.symlinkedDirectories = createMap();
1819+
const directoryPath = ensureTrailingDirectorySeparator(this.toPath(directory));
1820+
if (this.symlinkedDirectories.has(directoryPath)) return;
1821+
1822+
const real = this.projectService.host.realpath!(directory);
1823+
let realPath: Path;
1824+
if (real === directory ||
1825+
(realPath = ensureTrailingDirectorySeparator(this.toPath(real))) === directoryPath) {
1826+
// not symlinked
1827+
this.symlinkedDirectories.set(directoryPath, false);
1828+
return;
1829+
}
1830+
1831+
this.symlinkedDirectories.set(directoryPath, {
1832+
real: ensureTrailingDirectorySeparator(real),
1833+
realPath
1834+
});
1835+
}
1836+
1837+
private fileOrDirectoryExistsUsingSource(fileOrDirectory: string, isFile: boolean): boolean {
1838+
const fileOrDirectoryExistsUsingSource = isFile ?
1839+
(file: string) => this.fileExistsIfProjectReferenceDts(file) :
1840+
(dir: string) => this.directoryExistsIfProjectReferenceDeclDir(dir);
1841+
// Check current directory or file
1842+
const result = fileOrDirectoryExistsUsingSource(fileOrDirectory);
1843+
if (result !== undefined) return result;
1844+
1845+
if (!this.symlinkedDirectories) return false;
1846+
const fileOrDirectoryPath = this.toPath(fileOrDirectory);
1847+
if (!stringContains(fileOrDirectoryPath, nodeModulesPathPart)) return false;
1848+
if (isFile && this.symlinkedFiles && this.symlinkedFiles.has(fileOrDirectoryPath)) return true;
1849+
1850+
// If it contains node_modules check if its one of the symlinked path we know of
1851+
return firstDefinedIterator(
1852+
this.symlinkedDirectories.entries(),
1853+
([directoryPath, symlinkedDirectory]) => {
1854+
if (!symlinkedDirectory || !startsWith(fileOrDirectoryPath, directoryPath)) return undefined;
1855+
const result = fileOrDirectoryExistsUsingSource(fileOrDirectoryPath.replace(directoryPath, symlinkedDirectory.realPath));
1856+
if (isFile && result) {
1857+
if (!this.symlinkedFiles) this.symlinkedFiles = createMap();
1858+
// Store the real path for the file'
1859+
const absolutePath = getNormalizedAbsolutePath(fileOrDirectory, this.currentDirectory);
1860+
this.symlinkedFiles.set(
1861+
fileOrDirectoryPath,
1862+
`${symlinkedDirectory.real}${absolutePath.replace(new RegExp(directoryPath, "i"), "")}`
1863+
);
1864+
}
1865+
return result;
1866+
}
1867+
) || false;
17761868
}
17771869

17781870
/**
@@ -1785,6 +1877,8 @@ namespace ts.server {
17851877
this.pendingReload = ConfigFileProgramReloadLevel.None;
17861878
this.projectReferenceCallbacks = undefined;
17871879
this.mapOfDeclarationDirectories = undefined;
1880+
this.symlinkedDirectories = undefined;
1881+
this.symlinkedFiles = undefined;
17881882
let result: boolean;
17891883
switch (reloadLevel) {
17901884
case ConfigFileProgramReloadLevel.Partial:
@@ -1917,6 +2011,8 @@ namespace ts.server {
19172011
this.configFileSpecs = undefined;
19182012
this.projectReferenceCallbacks = undefined;
19192013
this.mapOfDeclarationDirectories = undefined;
2014+
this.symlinkedDirectories = undefined;
2015+
this.symlinkedFiles = undefined;
19202016
super.close();
19212017
}
19222018

src/testRunner/unittests/tsserver/projectReferences.ts

+93-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace ts.projectSystem {
22
describe("unittests:: tsserver:: with project references and tsbuild", () => {
3-
function createHost(files: readonly File[], rootNames: readonly string[]) {
3+
function createHost(files: readonly TestFSWithWatch.FileOrFolderOrSymLink[], rootNames: readonly string[]) {
44
const host = createServerHost(files);
55

66
// ts build should succeed
@@ -1373,5 +1373,97 @@ function foo() {
13731373
assert.isTrue(projectA.dirty);
13741374
projectA.updateGraph();
13751375
});
1376+
1377+
describe("when references are monorepo like with symlinks", () => {
1378+
function verifySession(alreadyBuilt: boolean, extraOptions: CompilerOptions) {
1379+
const bPackageJson: File = {
1380+
path: `${projectRoot}/packages/B/package.json`,
1381+
content: JSON.stringify({
1382+
main: "lib/index.js",
1383+
types: "lib/index.d.ts"
1384+
})
1385+
};
1386+
const aConfig = config("A", extraOptions, ["../B"]);
1387+
const bConfig = config("B", extraOptions);
1388+
const aIndex = index("A", `import { foo } from 'b';
1389+
import { bar } from 'b/lib/bar';
1390+
foo();
1391+
bar();`);
1392+
const bIndex = index("B", `export function foo() { }`);
1393+
const bBar: File = {
1394+
path: `${projectRoot}/packages/B/src/bar.ts`,
1395+
content: `export function bar() { }`
1396+
};
1397+
const bSymlink: SymLink = {
1398+
path: `${projectRoot}/node_modules/b`,
1399+
symLink: `${projectRoot}/packages/B`
1400+
};
1401+
1402+
const files = [libFile, bPackageJson, aConfig, bConfig, aIndex, bIndex, bBar, bSymlink];
1403+
const host = alreadyBuilt ?
1404+
createHost(files, [aConfig.path]) :
1405+
createServerHost(files);
1406+
1407+
// Create symlink in node module
1408+
const session = createSession(host, { canUseEvents: true });
1409+
openFilesForSession([aIndex], session);
1410+
const service = session.getProjectService();
1411+
const project = service.configuredProjects.get(aConfig.path.toLowerCase())!;
1412+
assert.deepEqual(project.getAllProjectErrors(), []);
1413+
checkProjectActualFiles(
1414+
project,
1415+
[aConfig.path, aIndex.path, bIndex.path, bBar.path, libFile.path]
1416+
);
1417+
verifyGetErrRequest({
1418+
host,
1419+
session,
1420+
expected: [
1421+
{ file: aIndex, syntax: [], semantic: [], suggestion: [] }
1422+
]
1423+
});
1424+
}
1425+
1426+
function verifySymlinkScenario(alreadyBuilt: boolean) {
1427+
it("with preserveSymlinks turned off", () => {
1428+
verifySession(alreadyBuilt, {});
1429+
});
1430+
1431+
it("with preserveSymlinks turned on", () => {
1432+
verifySession(alreadyBuilt, { preserveSymlinks: true });
1433+
});
1434+
}
1435+
1436+
describe("when solution is not built", () => {
1437+
verifySymlinkScenario(/*alreadyBuilt*/ false);
1438+
});
1439+
1440+
describe("when solution is already built", () => {
1441+
verifySymlinkScenario(/*alreadyBuilt*/ true);
1442+
});
1443+
1444+
function config(packageName: string, extraOptions: CompilerOptions, references?: string[]): File {
1445+
return {
1446+
path: `${projectRoot}/packages/${packageName}/tsconfig.json`,
1447+
content: JSON.stringify({
1448+
compilerOptions: {
1449+
baseUrl: ".",
1450+
outDir: "lib",
1451+
rootDir: "src",
1452+
composite: true,
1453+
...extraOptions
1454+
},
1455+
include: ["src"],
1456+
...(references ? { references: references.map(path => ({ path })) } : {})
1457+
})
1458+
};
1459+
}
1460+
1461+
function index(packageName: string, content: string): File {
1462+
return {
1463+
path: `${projectRoot}/packages/${packageName}/src/index.ts`,
1464+
content
1465+
};
1466+
}
1467+
});
13761468
});
13771469
}

tests/baselines/reference/api/tsserverlibrary.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -8684,23 +8684,31 @@ declare namespace ts.server {
86848684
readonly canonicalConfigFilePath: NormalizedPath;
86858685
private projectReferenceCallbacks;
86868686
private mapOfDeclarationDirectories;
8687+
private symlinkedDirectories;
8688+
private symlinkedFiles;
86878689
/** Ref count to the project when opened from external project */
86888690
private externalProjectRefCount;
86898691
private projectErrors;
86908692
private projectReferences;
86918693
protected isInitialLoadPending: () => boolean;
8694+
private fileExistsIfProjectReferenceDts;
86928695
/**
86938696
* This implementation of fileExists checks if the file being requested is
86948697
* .d.ts file for the referenced Project.
86958698
* If it is it returns true irrespective of whether that file exists on host
86968699
*/
86978700
fileExists(file: string): boolean;
8701+
private directoryExistsIfProjectReferenceDeclDir;
86988702
/**
86998703
* This implementation of directoryExists checks if the directory being requested is
87008704
* directory of .d.ts file for the referenced Project.
87018705
* If it is it returns true irrespective of whether that directory exists on host
87028706
*/
87038707
directoryExists(path: string): boolean;
8708+
private realpathIfSymlinkedProjectReferenceDts;
8709+
private getRealpath;
8710+
private handleDirectoryCouldBeSymlink;
8711+
private fileOrDirectoryExistsUsingSource;
87048712
/**
87058713
* If the project has reload from disk pending, it reloads (and then updates graph as part of that) instead of just updating the graph
87068714
* @returns: true if set of files in the project stays the same and false - otherwise.

0 commit comments

Comments
 (0)