Skip to content

This handles when packages are symbol links in mono repo like scenarios to use source files instead of output d.ts from project reference #34743

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Oct 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/compiler/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2271,7 +2271,19 @@ namespace ts {
// Get source file from normalized fileName
function findSourceFile(fileName: string, path: Path, isDefaultLib: boolean, ignoreNoDefaultLib: boolean, refFile: RefFile | undefined, packageId: PackageId | undefined): SourceFile | undefined {
if (useSourceOfProjectReferenceRedirect) {
const source = getSourceOfProjectReferenceRedirect(fileName);
let source = getSourceOfProjectReferenceRedirect(fileName);
// If preserveSymlinks is true, module resolution wont jump the symlink
// but the resolved real path may be the .d.ts from project reference
// Note:: Currently we try the real path only if the
// file is from node_modules to avoid having to run real path on all file paths
if (!source &&
host.realpath &&
options.preserveSymlinks &&
isDeclarationFileName(fileName) &&
stringContains(fileName, nodeModulesPathPart)) {
const realPath = host.realpath(fileName);
if (realPath !== fileName) source = getSourceOfProjectReferenceRedirect(realPath);
}
if (source) {
const file = isString(source) ?
findSourceFile(source, toPath(source), isDefaultLib, ignoreNoDefaultLib, refFile, packageId) :
Expand Down
128 changes: 112 additions & 16 deletions src/server/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ namespace ts.server {
private compilerOptions: CompilerOptions,
public compileOnSaveEnabled: boolean,
directoryStructureHost: DirectoryStructureHost,
currentDirectory: string | undefined) {
currentDirectory: string | undefined,
customRealpath?: (s: string) => string) {
this.directoryStructureHost = directoryStructureHost;
this.currentDirectory = this.projectService.getNormalizedAbsolutePath(currentDirectory || "");
this.getCanonicalFileName = this.projectService.toCanonicalFileName;
Expand Down Expand Up @@ -286,7 +287,7 @@ namespace ts.server {
}

if (host.realpath) {
this.realpath = path => host.realpath!(path);
this.realpath = customRealpath || (path => host.realpath!(path));
}

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

/*@internal*/
interface SymlinkedDirectory {
real: string;
realPath: Path;
}

/**
* If a file is opened, the server will look for a tsconfig (or jsconfig)
* and if successfull create a ConfiguredProject for it.
Expand All @@ -1673,6 +1680,8 @@ namespace ts.server {
readonly canonicalConfigFilePath: NormalizedPath;
private projectReferenceCallbacks: ResolvedProjectReferenceCallbacks | undefined;
private mapOfDeclarationDirectories: Map<true> | undefined;
private symlinkedDirectories: Map<SymlinkedDirectory | false> | undefined;
private symlinkedFiles: Map<string> | undefined;

/* @internal */
pendingReload: ConfigFileProgramReloadLevel | undefined;
Expand Down Expand Up @@ -1714,7 +1723,9 @@ namespace ts.server {
/*compilerOptions*/ {},
/*compileOnSaveEnabled*/ false,
cachedDirectoryStructureHost,
getDirectoryPath(configFileName));
getDirectoryPath(configFileName),
projectService.host.realpath && (s => this.getRealpath(s))
);
this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName));
}

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

private fileExistsIfProjectReferenceDts(file: string) {
const source = this.projectReferenceCallbacks!.getSourceOfProjectReferenceRedirect(file);
return source !== undefined ?
isString(source) ? super.fileExists(source) : true :
undefined;
}

/**
* This implementation of fileExists checks if the file being requested is
* .d.ts file for the referenced Project.
* If it is it returns true irrespective of whether that file exists on host
*/
fileExists(file: string): boolean {
if (super.fileExists(file)) return true;
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return false;
if (!isDeclarationFileName(file)) return false;

// Project references go to source file instead of .d.ts file
if (this.useSourceOfProjectReferenceRedirect() && this.projectReferenceCallbacks) {
const source = this.projectReferenceCallbacks.getSourceOfProjectReferenceRedirect(file);
if (source) return isString(source) ? super.fileExists(source) : true;
}
return super.fileExists(file);
return this.fileOrDirectoryExistsUsingSource(file, /*isFile*/ true);
}

private directoryExistsIfProjectReferenceDeclDir(dir: string) {
const dirPath = this.toPath(dir);
const dirPathWithTrailingDirectorySeparator = `${dirPath}${directorySeparator}`;
return forEachKey(
this.mapOfDeclarationDirectories!,
declDirPath => dirPath === declDirPath || startsWith(declDirPath, dirPathWithTrailingDirectorySeparator)
);
}

/**
Expand All @@ -1747,14 +1774,17 @@ namespace ts.server {
* If it is it returns true irrespective of whether that directory exists on host
*/
directoryExists(path: string): boolean {
if (super.directoryExists(path)) return true;
if (super.directoryExists(path)) {
this.handleDirectoryCouldBeSymlink(path);
return true;
}
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return false;

if (!this.mapOfDeclarationDirectories) {
this.mapOfDeclarationDirectories = createMap();
this.projectReferenceCallbacks.forEachResolvedProjectReference(ref => {
if (!ref) return;
const out = ref.commandLine.options.outFile || ref.commandLine.options.outDir;
const out = ref.commandLine.options.outFile || ref.commandLine.options.out;
if (out) {
this.mapOfDeclarationDirectories!.set(getDirectoryPath(this.toPath(out)), true);
}
Expand All @@ -1767,12 +1797,74 @@ namespace ts.server {
}
});
}
const dirPath = this.toPath(path);
const dirPathWithTrailingDirectorySeparator = `${dirPath}${directorySeparator}`;
return !!forEachKey(
this.mapOfDeclarationDirectories,
declDirPath => dirPath === declDirPath || startsWith(declDirPath, dirPathWithTrailingDirectorySeparator)
);

return this.fileOrDirectoryExistsUsingSource(path, /*isFile*/ false);
}

private realpathIfSymlinkedProjectReferenceDts(s: string): string | undefined {
return this.symlinkedFiles && this.symlinkedFiles.get(this.toPath(s));
}

private getRealpath(s: string): string {
return this.realpathIfSymlinkedProjectReferenceDts(s) ||
this.projectService.host.realpath!(s);
}

private handleDirectoryCouldBeSymlink(directory: string) {
if (!this.useSourceOfProjectReferenceRedirect() || !this.projectReferenceCallbacks) return;

// Because we already watch node_modules, handle symlinks in there
if (!this.realpath || !stringContains(directory, nodeModulesPathPart)) return;
if (!this.symlinkedDirectories) this.symlinkedDirectories = createMap();
const directoryPath = ensureTrailingDirectorySeparator(this.toPath(directory));
if (this.symlinkedDirectories.has(directoryPath)) return;

const real = this.projectService.host.realpath!(directory);
let realPath: Path;
if (real === directory ||
(realPath = ensureTrailingDirectorySeparator(this.toPath(real))) === directoryPath) {
// not symlinked
this.symlinkedDirectories.set(directoryPath, false);
return;
}

this.symlinkedDirectories.set(directoryPath, {
real: ensureTrailingDirectorySeparator(real),
realPath
});
}

private fileOrDirectoryExistsUsingSource(fileOrDirectory: string, isFile: boolean): boolean {
const fileOrDirectoryExistsUsingSource = isFile ?
(file: string) => this.fileExistsIfProjectReferenceDts(file) :
(dir: string) => this.directoryExistsIfProjectReferenceDeclDir(dir);
// Check current directory or file
const result = fileOrDirectoryExistsUsingSource(fileOrDirectory);
if (result !== undefined) return result;

if (!this.symlinkedDirectories) return false;
const fileOrDirectoryPath = this.toPath(fileOrDirectory);
if (!stringContains(fileOrDirectoryPath, nodeModulesPathPart)) return false;
if (isFile && this.symlinkedFiles && this.symlinkedFiles.has(fileOrDirectoryPath)) return true;

// If it contains node_modules check if its one of the symlinked path we know of
return firstDefinedIterator(
this.symlinkedDirectories.entries(),
([directoryPath, symlinkedDirectory]) => {
if (!symlinkedDirectory || !startsWith(fileOrDirectoryPath, directoryPath)) return undefined;
const result = fileOrDirectoryExistsUsingSource(fileOrDirectoryPath.replace(directoryPath, symlinkedDirectory.realPath));
if (isFile && result) {
if (!this.symlinkedFiles) this.symlinkedFiles = createMap();
// Store the real path for the file'
const absolutePath = getNormalizedAbsolutePath(fileOrDirectory, this.currentDirectory);
this.symlinkedFiles.set(
fileOrDirectoryPath,
`${symlinkedDirectory.real}${absolutePath.replace(new RegExp(directoryPath, "i"), "")}`
);
}
return result;
}
) || false;
}

/**
Expand All @@ -1785,6 +1877,8 @@ namespace ts.server {
this.pendingReload = ConfigFileProgramReloadLevel.None;
this.projectReferenceCallbacks = undefined;
this.mapOfDeclarationDirectories = undefined;
this.symlinkedDirectories = undefined;
this.symlinkedFiles = undefined;
let result: boolean;
switch (reloadLevel) {
case ConfigFileProgramReloadLevel.Partial:
Expand Down Expand Up @@ -1917,6 +2011,8 @@ namespace ts.server {
this.configFileSpecs = undefined;
this.projectReferenceCallbacks = undefined;
this.mapOfDeclarationDirectories = undefined;
this.symlinkedDirectories = undefined;
this.symlinkedFiles = undefined;
super.close();
}

Expand Down
94 changes: 93 additions & 1 deletion src/testRunner/unittests/tsserver/projectReferences.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace ts.projectSystem {
describe("unittests:: tsserver:: with project references and tsbuild", () => {
function createHost(files: readonly File[], rootNames: readonly string[]) {
function createHost(files: readonly TestFSWithWatch.FileOrFolderOrSymLink[], rootNames: readonly string[]) {
const host = createServerHost(files);

// ts build should succeed
Expand Down Expand Up @@ -1373,5 +1373,97 @@ function foo() {
assert.isTrue(projectA.dirty);
projectA.updateGraph();
});

describe("when references are monorepo like with symlinks", () => {
function verifySession(alreadyBuilt: boolean, extraOptions: CompilerOptions) {
const bPackageJson: File = {
path: `${projectRoot}/packages/B/package.json`,
content: JSON.stringify({
main: "lib/index.js",
types: "lib/index.d.ts"
})
};
const aConfig = config("A", extraOptions, ["../B"]);
const bConfig = config("B", extraOptions);
const aIndex = index("A", `import { foo } from 'b';
import { bar } from 'b/lib/bar';
foo();
bar();`);
const bIndex = index("B", `export function foo() { }`);
const bBar: File = {
path: `${projectRoot}/packages/B/src/bar.ts`,
content: `export function bar() { }`
};
const bSymlink: SymLink = {
path: `${projectRoot}/node_modules/b`,
symLink: `${projectRoot}/packages/B`
};

const files = [libFile, bPackageJson, aConfig, bConfig, aIndex, bIndex, bBar, bSymlink];
const host = alreadyBuilt ?
createHost(files, [aConfig.path]) :
createServerHost(files);

// Create symlink in node module
const session = createSession(host, { canUseEvents: true });
openFilesForSession([aIndex], session);
const service = session.getProjectService();
const project = service.configuredProjects.get(aConfig.path.toLowerCase())!;
assert.deepEqual(project.getAllProjectErrors(), []);
checkProjectActualFiles(
project,
[aConfig.path, aIndex.path, bIndex.path, bBar.path, libFile.path]
);
verifyGetErrRequest({
host,
session,
expected: [
{ file: aIndex, syntax: [], semantic: [], suggestion: [] }
]
});
}

function verifySymlinkScenario(alreadyBuilt: boolean) {
it("with preserveSymlinks turned off", () => {
verifySession(alreadyBuilt, {});
});

it("with preserveSymlinks turned on", () => {
verifySession(alreadyBuilt, { preserveSymlinks: true });
});
}

describe("when solution is not built", () => {
verifySymlinkScenario(/*alreadyBuilt*/ false);
});

describe("when solution is already built", () => {
verifySymlinkScenario(/*alreadyBuilt*/ true);
});

function config(packageName: string, extraOptions: CompilerOptions, references?: string[]): File {
return {
path: `${projectRoot}/packages/${packageName}/tsconfig.json`,
content: JSON.stringify({
compilerOptions: {
baseUrl: ".",
outDir: "lib",
rootDir: "src",
composite: true,
...extraOptions
},
include: ["src"],
...(references ? { references: references.map(path => ({ path })) } : {})
})
};
}

function index(packageName: string, content: string): File {
return {
path: `${projectRoot}/packages/${packageName}/src/index.ts`,
content
};
}
});
});
}
8 changes: 8 additions & 0 deletions tests/baselines/reference/api/tsserverlibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8683,23 +8683,31 @@ declare namespace ts.server {
readonly canonicalConfigFilePath: NormalizedPath;
private projectReferenceCallbacks;
private mapOfDeclarationDirectories;
private symlinkedDirectories;
private symlinkedFiles;
/** Ref count to the project when opened from external project */
private externalProjectRefCount;
private projectErrors;
private projectReferences;
protected isInitialLoadPending: () => boolean;
private fileExistsIfProjectReferenceDts;
/**
* This implementation of fileExists checks if the file being requested is
* .d.ts file for the referenced Project.
* If it is it returns true irrespective of whether that file exists on host
*/
fileExists(file: string): boolean;
private directoryExistsIfProjectReferenceDeclDir;
/**
* This implementation of directoryExists checks if the directory being requested is
* directory of .d.ts file for the referenced Project.
* If it is it returns true irrespective of whether that directory exists on host
*/
directoryExists(path: string): boolean;
private realpathIfSymlinkedProjectReferenceDts;
private getRealpath;
private handleDirectoryCouldBeSymlink;
private fileOrDirectoryExistsUsingSource;
/**
* If the project has reload from disk pending, it reloads (and then updates graph as part of that) instead of just updating the graph
* @returns: true if set of files in the project stays the same and false - otherwise.
Expand Down