Skip to content

build: add path mapping support to broccoli typescript #797

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 1 commit into from
May 17, 2016
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
120 changes: 117 additions & 3 deletions lib/broccoli/broccoli-typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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']) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why const here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I didn't realize const was block-scoped. Disregard.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still why const here and let in other for-of loops ? 👿

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;
}
Expand Down
44 changes: 33 additions & 11 deletions tests/e2e/e2e_workflow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
});
});

});