From 3b95ea460ea4a4f69e2cdf0871939462aac0632f Mon Sep 17 00:00:00 2001 From: Vladimir Matveev Date: Mon, 17 Aug 2015 18:31:12 -0700 Subject: [PATCH] initial implementation of module resolution for node/requirejs --- Jakefile.js | 3 +- src/compiler/program.ts | 160 +++++++++++++- src/compiler/types.ts | 10 +- tests/cases/unittests/moduleResolution.ts | 254 ++++++++++++++++++++++ 4 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 tests/cases/unittests/moduleResolution.ts diff --git a/Jakefile.js b/Jakefile.js index 396f15e2730..1e989bc5a65 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -143,7 +143,8 @@ var harnessSources = harnessCoreSources.concat([ "convertToBase64.ts", "transpile.ts", "reuseProgramStructure.ts", - "cachingInServerLSHost.ts" + "cachingInServerLSHost.ts", + "moduleResolution.ts" ].map(function (f) { return path.join(unittestsDirectory, f); })).concat([ diff --git a/src/compiler/program.ts b/src/compiler/program.ts index 8ceccc0e880..57df3decf55 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -36,11 +36,165 @@ namespace ts { } export function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModule { - // TODO: use different resolution strategy based on compiler options - return legacyNameResolver(moduleName, containingFile, compilerOptions, host); + switch(compilerOptions.moduleResolution) { + case ModuleResolutionKind.NodeJs: return nodeModuleNameResolver(moduleName, containingFile, compilerOptions, host); + case ModuleResolutionKind.BaseUrl: return baseUrlModuleNameResolver(moduleName, containingFile, compilerOptions, host); + default: return legacyNameResolver(moduleName, containingFile, compilerOptions, host); + } + } + + export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModule { + let containingDirectory = getDirectoryPath(containingFile); + + if (getRootLength(moduleName) !== 0 || nameStartsWithDotSlashOrDotDotSlash(moduleName)) { + let failedLookupLocations: string[] = []; + let candidate = normalizePath(combinePaths(containingDirectory, moduleName)); + let result = loadNodeModuleFromFile(candidate, failedLookupLocations, host); + + if (result) { + return { resolvedFileName: result, failedLookupLocations }; + } + + result = loadNodeModuleFromDirectory(candidate, failedLookupLocations, host); + return { resolvedFileName: result, failedLookupLocations }; + } + else { + return loadModuleModuleFromNodeModules(moduleName, containingDirectory, host); + } + } + + function loadNodeModuleFromFile(candidate: string, failedLookupLocation: string[], host: ModuleResolutionHost): string { + // load only .d.ts files + let fileName = fileExtensionIs(candidate, ".d.ts") ? candidate : candidate + ".d.ts"; + if (!host.fileExists(fileName)) { + failedLookupLocation.push(fileName); + return undefined; + } + + return fileName; + } + + function loadNodeModuleFromDirectory(candidate: string, failedLookupLocation: string[], host: ModuleResolutionHost): string { + let packageJsonPath = combinePaths(candidate, "package.json"); + if (host.fileExists(packageJsonPath)) { + let jsonText = host.readFile(packageJsonPath); + let jsonContent = jsonText ? <{ typings?: string, main?: string }>JSON.parse(jsonText) : { typings: undefined, main:undefined }; + if (jsonContent.typings) { + let result = loadNodeModuleFromFile(normalizePath(combinePaths(candidate, jsonContent.typings)), failedLookupLocation, host); + if (result) { + return result; + } + } + + if (jsonContent.main) { + let mainFile = removeFileExtension(jsonContent.main); + let result = loadNodeModuleFromFile(normalizePath(combinePaths(candidate, mainFile)), failedLookupLocation, host); + if (result) { + return result; + } + } + } + else { + // record package json as one of failed lookup locations - in the future if this file will appear it will invalidate resolution results + failedLookupLocation.push(packageJsonPath); + } + + return loadNodeModuleFromFile(combinePaths(candidate, "index"), failedLookupLocation, host); + } + + function loadModuleModuleFromNodeModules(moduleName: string, directory: string, host: ModuleResolutionHost): ResolvedModule { + let failedLookupLocations: string[] = []; + directory = normalizeSlashes(directory); + while (true) { + let baseName = getBaseFileName(directory); + if (baseName !== "node_modules") { + let nodeModulesFolder = combinePaths(directory, "node_modules"); + let candidate = normalizePath(combinePaths(nodeModulesFolder, moduleName)); + let result = loadNodeModuleFromFile(candidate, failedLookupLocations, host); + if (result) { + return { resolvedFileName: result, failedLookupLocations }; + } + + result = loadNodeModuleFromDirectory(candidate, failedLookupLocations, host); + if (result) { + return { resolvedFileName: result, failedLookupLocations }; + } + } + + let parentPath = getDirectoryPath(directory); + if (parentPath === directory) { + break; + } + + directory = parentPath; + } + + return { resolvedFileName: undefined, failedLookupLocations }; + } + + export function baseUrlModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModule { + Debug.assert(compilerOptions.baseUrl !== undefined, "baseUrl is mandatory when using this module resolution strategy"); + + let normalizedModuleName = normalizeSlashes(moduleName); + + // treat module name as url that is relative to containing file if + let basePart = useBaseUrl(moduleName) ? compilerOptions.baseUrl : getDirectoryPath(containingFile); + let candidate = normalizePath(combinePaths(basePart, moduleName)); + + + let failedLookupLocations: string[] = []; + // first - try to load file as is + let result = tryLoadFile(candidate); + if (result) { + return result; + } + // then try all supported extension + for(let ext of supportedExtensions) { + let result = tryLoadFile(candidate + ext); + if (result) { + return result; + } + } + + return { resolvedFileName: undefined, failedLookupLocations }; + + function tryLoadFile(location: string): ResolvedModule { + if (host.fileExists(location)) { + return { resolvedFileName: location, failedLookupLocations }; + } + else { + failedLookupLocations.push(location); + return undefined; + } + } + } + + function nameStartsWithDotSlashOrDotDotSlash(name: string) { + let i = name.lastIndexOf("./", 1); + return i === 0 || (i === 1 && name.charCodeAt(0) === CharacterCodes.dot); + } + + function useBaseUrl(moduleName: string): boolean { + // path is rooted + if (getRootLength(moduleName) !== 0) { + return false; + } + + // module name starts with './' or '../' + if (nameStartsWithDotSlashOrDotDotSlash(moduleName)) { + return false; + } + + // module name has one of supported extesions + for(let ext of supportedExtensions ) { + if (fileExtensionIs(moduleName, ext)) { + return false; + } + } + return true; } - function legacyNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModule { + export function legacyNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModule { // module names that contain '!' are used to reference resources and are not resolved to actual files on disk if (moduleName.indexOf('!') != -1) { diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 326cd2bacba..853a83aab31 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -2004,7 +2004,13 @@ namespace ts { Error, Message, } - + + export const enum ModuleResolutionKind { + Legacy = 1, + NodeJs = 2, + BaseUrl = 3 + } + export interface CompilerOptions { allowNonTsExtensions?: boolean; charset?: string; @@ -2043,6 +2049,8 @@ namespace ts { experimentalDecorators?: boolean; experimentalAsyncFunctions?: boolean; emitDecoratorMetadata?: boolean; + moduleResolution?: ModuleResolutionKind + baseUrl?: string; /* @internal */ stripInternal?: boolean; // Skip checking lib.d.ts to help speed up tests. diff --git a/tests/cases/unittests/moduleResolution.ts b/tests/cases/unittests/moduleResolution.ts new file mode 100644 index 00000000000..7ae2cbe6ec5 --- /dev/null +++ b/tests/cases/unittests/moduleResolution.ts @@ -0,0 +1,254 @@ +/// +/// + +declare namespace chai.assert { + function deepEqual(actual: any, expected: any): void; +} + +module ts { + + interface Directory { + name: string; + children: Map; + } + + interface File { + name: string + content?: string + } + + function createModuleResolutionHost(...files: File[]): ModuleResolutionHost { + let root = makeFS(files); + + return { fileExists, readFile }; + + function fileExists(path: string): boolean { + return findFile(path, root) !== undefined; + } + + function readFile(path: string): string { + let f = findFile(path, root); + return f && f.content; + } + + function findFile(path: string, fse: File | Directory): File { + if (!fse) { + return undefined; + } + + if (isDirectory(fse)) { + let {dir, rel} = splitPath(path); + return findFile(rel, (fse).children[dir]); + } + else { + return !path && fse; + } + } + } + + function isDirectory(fse: Directory | File): boolean { + return (fse).children !== undefined; + } + + function createDirectory(name: string): Directory { + return { name, children: {} } + } + + function makeFS(files: File[]): Directory { + // create root + let {dir} = splitPath(files[0].name); + let root: Directory = createDirectory(dir); + + for(let f of files) { + addFile(f.name, f.content, root); + } + + function addFile(path: string, content: string, parent: Directory) { + Debug.assert(parent !== undefined); + + let {dir, rel} = splitPath(path); + if (rel) { + let d = parent.children[dir] || (parent.children[dir] = createDirectory(dir)); + Debug.assert(isDirectory(d)) + addFile(rel, content, d); + } + else { + parent.children[dir] = { name: dir, content }; + } + } + + return root; + } + + function splitPath(path: string): { dir: string; rel: string } { + let index = path.indexOf(directorySeparator); + return index === -1 + ? { dir: path, rel: undefined } + : { dir: path.substr(0, index), rel: path.substr(index + 1) }; + } + + let opts: CompilerOptions = { moduleResolution: ModuleResolutionKind.NodeJs }; + + describe("Node module resolution - relative paths", () => { + + function testLoadAsFile(containingFileName: string, moduleFileNameNoExt: string, moduleName: string): void { + { + // loading only .d.ts files + + let containingFile = { name: containingFileName, content: ""} + let moduleFile = { name: moduleFileNameNoExt + ".d.ts", content: "var x;"} + let resolution = nodeModuleNameResolver(moduleName, containingFile.name, opts, createModuleResolutionHost(containingFile, moduleFile)); + + assert.equal(resolution.resolvedFileName, moduleFile.name); + assert.isTrue(resolution.failedLookupLocations.length === 0); + } + { + // does not try to load .ts files + + let containingFile = { name: containingFileName, content: ""} + let moduleFile = { name: moduleFileNameNoExt + ".ts", content: "var x;"} + let resolution = nodeModuleNameResolver(moduleName, containingFile.name, opts, createModuleResolutionHost(containingFile, moduleFile)); + + assert.equal(resolution.resolvedFileName, undefined); + assert.equal(resolution.failedLookupLocations.length, 3); + assert.deepEqual(resolution.failedLookupLocations, [ + moduleFileNameNoExt + ".d.ts", + moduleFileNameNoExt + "/package.json", + moduleFileNameNoExt + "/index.d.ts" + ]) + } + } + + it("module name that starts with './' resolved as relative file name", () => { + testLoadAsFile("/foo/bar/baz.ts", "/foo/bar/foo", "./foo"); + }); + + it("module name that starts with '../' resolved as relative file name", () => { + testLoadAsFile("/foo/bar/baz.ts", "/foo/foo", "../foo"); + }); + + it("module name that starts with '/' script extension resolved as relative file name", () => { + testLoadAsFile("/foo/bar/baz.ts", "/foo", "/foo"); + }); + + function testLoadingFromPackageJson(containingFileName: string, packageJsonFileName: string, fieldName: string, fieldRef: string, moduleFileName: string, moduleName: string): void { + let containingFile = { name: containingFileName }; + let packageJson = { name: packageJsonFileName, content: JSON.stringify({ [fieldName]: fieldRef }) }; + let moduleFile = { name: moduleFileName }; + let resolution = nodeModuleNameResolver(moduleName, containingFile.name, opts, createModuleResolutionHost(containingFile, packageJson, moduleFile)); + assert.equal(resolution.resolvedFileName, moduleFile.name); + // expect one failed lookup location - attempt to load module as file + assert.equal(resolution.failedLookupLocations.length, 1); + } + + it("module name as directory - load from typings", () => { + testLoadingFromPackageJson("/a/b/c/d.ts", "/a/b/c/bar/package.json", "typings", "c/d/e.d.ts", "/a/b/c/bar/c/d/e.d.ts", "./bar"); + testLoadingFromPackageJson("/a/b/c/d.ts", "/a/bar/package.json", "typings", "e.d.ts", "/a/bar/e.d.ts", "../../bar"); + testLoadingFromPackageJson("/a/b/c/d.ts", "/bar/package.json", "typings", "e.d.ts", "/bar/e.d.ts", "/bar"); + }); + + it("module name as directory - load from main", () => { + testLoadingFromPackageJson("/a/b/c/d.ts", "/a/b/c/bar/package.json", "main", "c/d/e.d.ts", "/a/b/c/bar/c/d/e.d.ts", "./bar"); + testLoadingFromPackageJson("/a/b/c/d.ts", "/a/bar/package.json", "main", "e.d.ts", "/a/bar/e.d.ts", "../../bar"); + testLoadingFromPackageJson("/a/b/c/d.ts", "/bar/package.json", "main", "e.d.ts", "/bar/e.d.ts", "/bar"); + }); + + it ("module name as directory - load index.d.ts", () => { + let containingFile = {name: "/a/b/c.ts"}; + let packageJson = {name: "/a/b/foo/package.json", content: JSON.stringify({main: "/c/d"})}; + let indexFile = { name: "/a/b/foo/index.d.ts" }; + let resolution = nodeModuleNameResolver("./foo", containingFile.name, opts, createModuleResolutionHost(containingFile, packageJson, indexFile)); + assert.equal(resolution.resolvedFileName, indexFile.name); + // expect 2 failed lookup locations: + assert.deepEqual(resolution.failedLookupLocations, [ + "/a/b/foo.d.ts", + "/c/d.d.ts" + ]); + }); + }); + + describe("Node module resolution - non-relative paths", () => { + it("load module as file - ts files not loaded", () => { + let containingFile = { name: "/a/b/c/d/e.ts" }; + let moduleFile = { name: "/a/b/node_modules/foo.ts" }; + let resolution = nodeModuleNameResolver("foo", containingFile.name, opts, createModuleResolutionHost(containingFile, moduleFile)); + assert.equal(resolution.resolvedFileName, undefined); + assert.deepEqual(resolution.failedLookupLocations, [ + "/a/b/c/d/node_modules/foo.d.ts", + "/a/b/c/d/node_modules/foo/package.json", + "/a/b/c/d/node_modules/foo/index.d.ts", + "/a/b/c/node_modules/foo.d.ts", + "/a/b/c/node_modules/foo/package.json", + "/a/b/c/node_modules/foo/index.d.ts", + "/a/b/node_modules/foo.d.ts", + "/a/b/node_modules/foo/package.json", + "/a/b/node_modules/foo/index.d.ts", + "/a/node_modules/foo.d.ts", + "/a/node_modules/foo/package.json", + "/a/node_modules/foo/index.d.ts", + "/node_modules/foo.d.ts", + "/node_modules/foo/package.json", + "/node_modules/foo/index.d.ts" + ]) + }); + + it("load module as file", () => { + let containingFile = { name: "/a/b/c/d/e.ts" }; + let moduleFile = { name: "/a/b/node_modules/foo.d.ts" }; + let resolution = nodeModuleNameResolver("foo", containingFile.name, opts, createModuleResolutionHost(containingFile, moduleFile)); + assert.equal(resolution.resolvedFileName, moduleFile.name); + }); + + it("load module as directory", () => { + let containingFile = { name: "/a/node_modules/b/c/node_modules/d/e.ts" }; + let moduleFile = { name: "/a/node_modules/foo/index.d.ts" }; + let resolution = nodeModuleNameResolver("foo", containingFile.name, opts, createModuleResolutionHost(containingFile, moduleFile)); + assert.equal(resolution.resolvedFileName, moduleFile.name); + assert.deepEqual(resolution.failedLookupLocations, [ + "/a/node_modules/b/c/node_modules/d/node_modules/foo.d.ts", + "/a/node_modules/b/c/node_modules/d/node_modules/foo/package.json", + "/a/node_modules/b/c/node_modules/d/node_modules/foo/index.d.ts", + "/a/node_modules/b/c/node_modules/foo.d.ts", + "/a/node_modules/b/c/node_modules/foo/package.json", + "/a/node_modules/b/c/node_modules/foo/index.d.ts", + "/a/node_modules/b/node_modules/foo.d.ts", + "/a/node_modules/b/node_modules/foo/package.json", + "/a/node_modules/b/node_modules/foo/index.d.ts", + "/a/node_modules/foo.d.ts", + "/a/node_modules/foo/package.json" + ]); + }); + }); + + describe("BaseUrl mode", () => { + function getCompilerOptions(baseUrl: string): CompilerOptions { + return { baseUrl, moduleResolution: ModuleResolutionKind.BaseUrl }; + } + + it ("load module as relative url", () => { + function test(containingFileName: string, moduleFileName: string, moduleName: string): void { + let containingFile = {name: containingFileName }; + let moduleFile = { name: moduleFileName }; + let resolution = baseUrlModuleNameResolver(moduleName, containingFile.name, getCompilerOptions(""), createModuleResolutionHost(containingFile, moduleFile)); + assert.equal(resolution.resolvedFileName, moduleFile.name); + } + + test("/a/b/c/d.ts", "/foo.ts", "/foo.ts"); + test("/a/b/c/d.ts", "/foo.ts", "/foo"); + test("/a/b/c/d.ts", "/foo.d.ts", "/foo"); + test("/a/b/c/d.ts", "/foo.tsx", "/foo"); + + test("/a/b/c/d.ts", "/a/b/c/foo.ts", "./foo"); + test("/a/b/c/d.ts", "/a/b/c/foo.d.ts", "./foo"); + test("/a/b/c/d.ts", "/a/b/c/foo.tsx", "./foo"); + + test("/a/b/c/d.ts", "/a/b/foo.ts", "../foo"); + test("/a/b/c/d.ts", "/a/b/foo.d.ts", "../foo"); + test("/a/b/c/d.ts", "/a/b/foo.tsx", "../foo"); + + test("/a/b/c/d.ts", "/a/b/c/foo.ts", "foo.ts"); + test("/a/b/c/d.ts", "/a/b/c/foo.tsx", "foo.tsx"); + test("/a/b/c/d.ts", "/a/b/c/foo.d.ts", "foo.d.ts"); + }); + }); +} \ No newline at end of file