From 211e124cfe43f437f29964b6edf982aecb281671 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Fri, 13 May 2016 17:53:58 -0700 Subject: [PATCH] build: add path mapping support to broccoli typescript --- lib/broccoli/broccoli-typescript.js | 120 +++++++++++++++++++++++++++- tests/e2e/e2e_workflow.spec.js | 44 +++++++--- 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/lib/broccoli/broccoli-typescript.js b/lib/broccoli/broccoli-typescript.js index f6971debb1ee..016335a8b47b 100644 --- a/lib/broccoli/broccoli-typescript.js +++ b/lib/broccoli/broccoli-typescript.js @@ -159,12 +159,14 @@ class BroccoliTypeScriptCompiler extends Plugin { this._tsConfigFiles = tsconfig.files.splice(0); - this._tsOpts = ts.convertCompilerOptionsFromJson(tsconfig.compilerOptions, '', null).options; + this._tsOpts = ts.convertCompilerOptionsFromJson(tsconfig['compilerOptions'], + this.inputPaths[0], this._tsConfigPath).options; this._tsOpts.rootDir = ''; this._tsOpts.outDir = ''; this._tsServiceHost = new CustomLanguageServiceHost( - this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0]); + this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0], + tsconfig['compilerOptions'].paths, this._tsConfigPath); this._tsService = ts.createLanguageService(this._tsServiceHost, ts.createDocumentRegistry()); } @@ -249,13 +251,15 @@ class BroccoliTypeScriptCompiler extends Plugin { } class CustomLanguageServiceHost { - constructor(compilerOptions, fileNames, fileRegistry, treeInputPath) { + constructor(compilerOptions, fileNames, fileRegistry, treeInputPath, paths, tsConfigPath) { this.compilerOptions = compilerOptions; this.fileNames = fileNames; this.fileRegistry = fileRegistry; this.treeInputPath = treeInputPath; this.currentDirectory = treeInputPath; this.defaultLibFilePath = ts.getDefaultLibFilePath(compilerOptions).replace(/\\/g, '/'); + this.paths = paths; + this.tsConfigPath = tsConfigPath; this.projectVersion = 0; } @@ -272,6 +276,80 @@ class CustomLanguageServiceHost { return this.projectVersion.toString(); } + /** + * Resolve a moduleName based on the path mapping defined in the tsconfig. + * @param moduleName The module name to resolve. + * @returns {string|boolean} A string that is the path of the module, if found, or a boolean + * indicating if resolution should continue with default. + * @private + */ + _resolveModulePathWithMapping(moduleName) { + // check if module name should be used as-is or it should be mapped to different value + let longestMatchedPrefixLength = 0; + let matchedPattern; + let matchedWildcard; + const paths = this.paths || {}; + + for (let pattern of Object.keys(paths)) { + if (pattern.indexOf('*') != pattern.lastIndexOf('*')) { + throw `Invalid path mapping pattern: "${pattern}"`; + } + + let indexOfWildcard = pattern.indexOf('*'); + if (indexOfWildcard !== -1) { + // check if module name starts with prefix, ends with suffix and these two don't overlap + let prefix = pattern.substr(0, indexOfWildcard); + let suffix = pattern.substr(indexOfWildcard + 1); + if (moduleName.length >= prefix.length + suffix.length && + moduleName.startsWith(prefix) && + moduleName.endsWith(suffix)) { + + // use length of matched prefix as betterness criteria + if (longestMatchedPrefixLength < prefix.length) { + longestMatchedPrefixLength = prefix.length; + matchedPattern = pattern; + matchedWildcard = moduleName.substr(prefix.length, moduleName.length - suffix.length); + } + } + } else { + // Pattern does not contain asterisk - module name should exactly match pattern to succeed. + if (pattern === moduleName) { + matchedPattern = pattern; + matchedWildcard = undefined; + break; + } + } + } + + if (!matchedPattern) { + // We fallback to the old module resolution. + return true; + } + + // some pattern was matched - module name needs to be substituted + let substitutions = this.paths[matchedPattern]; + for (let subst of substitutions) { + if (subst.indexOf('*') != subst.lastIndexOf('*')) { + throw `Invalid substitution: "${subst}" for pattern "${matchedPattern}".`; + } + if (subst == '*') { + // Trigger default module resolution. + return true; + } + // replace * in substitution with matched wildcard + let p = matchedWildcard ? subst.replace('*', matchedWildcard) : subst; + // if substituion is a relative path - combine it with baseUrl + p = path.isAbsolute(p) ? p : path.join(this.treeInputPath, path.dirname(this.tsConfigPath), p); + if (fs.existsSync(p)) { + return p; + } + } + + // This is an error; there was a match but no corresponding mapping was valid. + // Do not call the default module resolution. + return false; + } + /** * This method is called quite a bit to lookup 3 kinds of paths: * 1/ files in the fileRegistry @@ -310,6 +388,42 @@ class CustomLanguageServiceHost { return ts.ScriptSnapshot.fromString(fs.readFileSync(absoluteTsFilePath, FS_OPTS)); } + resolveModuleNames(moduleNames, containingFile)/*: ResolvedModule[]*/ { + return moduleNames.map((moduleName) => { + let shouldResolveUsingDefaultMethod = false; + for (const ext of ['ts', 'd.ts']) { + const name = `${moduleName}.${ext}`; + const maybeModule = this._resolveModulePathWithMapping(name, containingFile); + if (typeof maybeModule == 'string') { + return { + resolvedFileName: maybeModule, + isExternalLibraryImport: false + }; + } else { + shouldResolveUsingDefaultMethod = shouldResolveUsingDefaultMethod || maybeModule; + } + } + + return shouldResolveUsingDefaultMethod && + ts.resolveModuleName(moduleName, containingFile, this.compilerOptions, { + fileExists(fileName) { + return fs.existsSync(fileName); + }, + readFile(fileName) { + return fs.readFileSync(fileName, 'utf-8'); + }, + directoryExists(directoryName) { + try { + const stats = fs.statSync(directoryName); + return stats && stats.isDirectory(); + } catch (e) { + return false; + } + } + }).resolvedModule; + }); + } + getCurrentDirectory() { return this.currentDirectory; } diff --git a/tests/e2e/e2e_workflow.spec.js b/tests/e2e/e2e_workflow.spec.js index c68b0c8afcbc..24611307d9cc 100644 --- a/tests/e2e/e2e_workflow.spec.js +++ b/tests/e2e/e2e_workflow.spec.js @@ -308,30 +308,52 @@ describe('Basic end-to-end Workflow', function () { }); }); - it('Turn on `noImplicitAny` in tsconfig.json and rebuild', function (done) { + it('Turn on `noImplicitAny` in tsconfig.json and rebuild', function () { this.timeout(420000); const configFilePath = path.join(process.cwd(), 'src', 'tsconfig.json'); let config = require(configFilePath); config.compilerOptions.noImplicitAny = true; - fs.writeFileSync(configFilePath, JSON.stringify(config), 'utf8'); + fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8'); sh.rm('-rf', path.join(process.cwd(), 'dist')); return ng(['build']) - .then(function () { + .then(() => { expect(existsSync(path.join(process.cwd(), 'dist'))).to.be.equal(true); - }) + }); + }); + + it('Turn on path mapping in tsconfig.json and rebuild', function () { + this.timeout(420000); + + const configFilePath = path.join(process.cwd(), 'src', 'tsconfig.json'); + let config = require(configFilePath); + + config.compilerOptions.baseUrl = ''; + + // This should fail. + config.compilerOptions.paths = { '@angular/*': [] }; + fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8'); + + return ng(['build']) .catch(() => { - throw new Error('Build failed.'); + return true; }) - .finally(function () { - // Clean `tmp` folder - process.chdir(path.resolve(root, '..')); - // sh.rm('-rf', './tmp'); // tmp.teardown takes too long - done(); + .then((passed) => { + expect(passed).to.equal(true); + }) + .then(() => { + // This should succeed. + config.compilerOptions.paths = { + '@angular/*': [ '*' ] + }; + fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8'); + }) + .then(() => ng(['build'])) + .catch(() => { + expect('build failed where it should have succeeded').to.equal(''); }); }); - });