From 78946fe9fae0dca554a759a41b9fcfa32dc64de9 Mon Sep 17 00:00:00 2001 From: Alex Eagle Date: Thu, 28 Apr 2016 21:57:16 -0700 Subject: [PATCH] feat(offline compiler): a replacement for tsc that compiles templates see #7483. --- .gitignore | 3 + gulpfile.js | 60 +++++-- tools/build/linknodemodules.js | 31 ++-- tools/compiler_cli/README.md | 72 +++++++++ tools/compiler_cli/src/codegen.ts | 142 +++++++++++++++++ tools/compiler_cli/src/compiler_host.ts | 63 ++++++++ tools/compiler_cli/src/index.ts | 55 +++++++ tools/compiler_cli/src/reflector_host.ts | 147 ++++++++++++++++++ tools/compiler_cli/src/tsc.ts | 98 ++++++++++++ tools/compiler_cli/src/tsconfig.json | 20 +++ .../test/src/a/multiple_components.html | 1 + .../test/src/a/multiple_components.ts | 21 +++ tools/compiler_cli/test/src/basic.html | 2 + tools/compiler_cli/test/src/basic.ts | 14 ++ tools/compiler_cli/test/src/bootstrap.ts | 8 + tools/compiler_cli/test/src/dep.d.ts | 2 + tools/compiler_cli/test/tsconfig.json | 27 ++++ 17 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 tools/compiler_cli/README.md create mode 100644 tools/compiler_cli/src/codegen.ts create mode 100644 tools/compiler_cli/src/compiler_host.ts create mode 100644 tools/compiler_cli/src/index.ts create mode 100644 tools/compiler_cli/src/reflector_host.ts create mode 100644 tools/compiler_cli/src/tsc.ts create mode 100644 tools/compiler_cli/src/tsconfig.json create mode 100644 tools/compiler_cli/test/src/a/multiple_components.html create mode 100644 tools/compiler_cli/test/src/a/multiple_components.ts create mode 100644 tools/compiler_cli/test/src/basic.html create mode 100644 tools/compiler_cli/test/src/basic.ts create mode 100644 tools/compiler_cli/test/src/bootstrap.ts create mode 100644 tools/compiler_cli/test/src/dep.d.ts create mode 100644 tools/compiler_cli/test/tsconfig.json diff --git a/.gitignore b/.gitignore index 8060eef4d4c04..1264ee5e778ac 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ tmp *.js.deps *.js.map +# Files created by the template compiler +**/*.ngfactory.ts + # Or type definitions we mirror from github # (NB: these lines are removed in publish-build-artifacts.sh) **/typings/**/*.d.ts diff --git a/gulpfile.js b/gulpfile.js index 8706e269d2ac1..2475b6255db4c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -452,22 +452,40 @@ gulp.task('serve.e2e.dart', ['build.js.cjs'], function(neverDone) { // ------------------ // CI tests suites -function runKarma(configFile, done) { +function execProcess(name, args, done) { var exec = require('child_process').exec; - var cmd = process.platform === 'win32' ? 'node_modules\\.bin\\karma run ' : - 'node node_modules/.bin/karma run '; - cmd += configFile; - exec(cmd, function(e, stdout) { + var cmd = process.platform === 'win32' ? 'node_modules\\.bin\\' + name + ' ' : + 'node node_modules/.bin/' + name + ' '; + cmd += args; + exec(cmd, done); +} +function runKarma(configFile, done) { + execProcess('karma', 'run ' + configFile, function(e, stdout) { // ignore errors, we don't want to fail the build in the interactive (non-ci) mode // karma server will print all test failures done(); }); } +// Gulp-typescript doesn't work with typescript@next: +// https://github.com/ivogabe/gulp-typescript/issues/331 +function runTsc(project, done) { + execProcess('tsc', '-p ' + project, function(e, stdout, stderr) { + if (e) { + console.log(stdout); + console.error(stderr); + done(e); + } else { + done(); + } + }); +} + gulp.task('test.js', function(done) { runSequence('test.unit.tools/ci', 'test.transpiler.unittest', 'test.unit.js/ci', - 'test.unit.cjs/ci', 'test.typings', 'check-public-api', sequenceComplete(done)); + 'test.unit.cjs/ci', 'test.compiler_cli', 'test.typings', 'check-public-api', + sequenceComplete(done)); }); gulp.task('test.dart', function(done) { @@ -768,7 +786,7 @@ gulp.task('!checkAndReport.payload.js', function() { { failConditions: PAYLOAD_TESTS_CONFIG.ts[packaging].sizeLimits, prefix: caseName + '_' + packaging - }) + }); } return PAYLOAD_TESTS_CONFIG.ts.cases.reduce(function(sizeReportingStreams, caseName) { @@ -1026,6 +1044,26 @@ gulp.task('!test.typings', gulp.task('test.typings', ['build.js.cjs'], function(done) { runSequence('!test.typings', sequenceComplete(done)); }); +gulp.task('!build.compiler_cli', ['build.js.cjs'], + function(done) { runTsc('tools/compiler_cli/src', done); }); + +gulp.task('!test.compiler_cli.codegen', function(done) { + try { + require('./dist/js/cjs/compiler_cli') + .main("tools/compiler_cli/test") + .then(function() { done(); }) + .catch(function(rej) { done(new Error(rej)); }); + } catch (err) { + done(err); + } +}); + +// End-to-end test for compiler CLI. +// Calls the compiler using its command-line interface, then compiles the app with the codegen. +// TODO(alexeagle): wire up the playground tests with offline compilation, similar to dart. +gulp.task('test.compiler_cli', ['!build.compiler_cli'], + function(done) { runSequence('!test.compiler_cli.codegen', sequenceComplete(done)); }); + // ----------------- // orchestrated targets @@ -1091,7 +1129,7 @@ gulp.task('!build.tools', function() { var sourcemaps = require('gulp-sourcemaps'); var tsc = require('gulp-typescript'); - var stream = gulp.src(['tools/**/*.ts']) + var stream = gulp.src(['tools/**/*.ts', '!tools/compiler_cli/**']) .pipe(sourcemaps.init()) .pipe(tsc({ target: 'ES5', @@ -1512,7 +1550,7 @@ gulp.on('task_start', (e) => { analytics.buildSuccess('gulp ', process.uptime() * 1000); } - analytics.buildStart('gulp ' + e.task) + analytics.buildStart('gulp ' + e.task); }); -gulp.on('task_stop', (e) => {analytics.buildSuccess('gulp ' + e.task, e.duration * 1000)}); -gulp.on('task_err', (e) => {analytics.buildError('gulp ' + e.task, e.duration * 1000)}); +gulp.on('task_stop', (e) => { analytics.buildSuccess('gulp ' + e.task, e.duration * 1000); }); +gulp.on('task_err', (e) => { analytics.buildError('gulp ' + e.task, e.duration * 1000); }); diff --git a/tools/build/linknodemodules.js b/tools/build/linknodemodules.js index 8b76c9477f517..101234a562b9a 100644 --- a/tools/build/linknodemodules.js +++ b/tools/build/linknodemodules.js @@ -2,6 +2,21 @@ var fs = require('fs'); var path = require('path'); module.exports = function(gulp, plugins, config) { + function symlink(relativeFolder, linkDir) { + var sourceDir = path.join('..', relativeFolder); + if (!fs.existsSync(linkDir)) { + console.log('creating link', linkDir, sourceDir); + try { + fs.symlinkSync(sourceDir, linkDir, 'dir'); + } + catch(e) { + var sourceDir = path.join(config.dir, relativeFolder); + console.log('linking failed: trying to hard copy', linkDir, sourceDir); + copyRecursiveSync(sourceDir, linkDir); + } + } + } + return function() { var nodeModulesDir = path.join(config.dir, 'node_modules'); if (!fs.existsSync(nodeModulesDir)) { @@ -11,20 +26,12 @@ module.exports = function(gulp, plugins, config) { if (relativeFolder === 'node_modules') { return; } - var sourceDir = path.join('..', relativeFolder); + var linkDir = path.join(nodeModulesDir, relativeFolder); - if (!fs.existsSync(linkDir)) { - console.log('creating link', linkDir, sourceDir); - try { - fs.symlinkSync(sourceDir, linkDir, 'dir'); - } - catch(e) { - var sourceDir = path.join(config.dir, relativeFolder); - console.log('linking failed: trying to hard copy', linkDir, sourceDir); - copyRecursiveSync(sourceDir, linkDir); - } - } + symlink(relativeFolder, linkDir); }); + // Also symlink tools we release independently to NPM, so tests can require metadata, etc. + symlink('../../tools/metadata', path.join(nodeModulesDir, 'ts-metadata-collector')); }; }; diff --git a/tools/compiler_cli/README.md b/tools/compiler_cli/README.md new file mode 100644 index 0000000000000..1479367baf5aa --- /dev/null +++ b/tools/compiler_cli/README.md @@ -0,0 +1,72 @@ +# Angular Template Compiler + +Angular applications are built with templates, which may be `.html` or `.css` files, +or may be inline `template` attributes on Decorators like `@Component`. + +These templates are compiled into executable JS at application runtime (except in `interpretation` mode). +This compilation can occur on the client, but it results in slower bootstrap time, and also +requires that the compiler be included in the code downloaded to the client. + +You can produce smaller, faster applications by running Angular's compiler as a build step, +and then downloading only the executable JS to the client. + +## Configuration + +The `tsconfig.json` file is expected to contain an additional configuration block: +``` + "angularCompilerOptions": { + "genDir": "." + } +``` +the `genDir` option controls the path (relative to `tsconfig.json`) where the generated file tree +will be written. More options may be added as we implement more features. + +We recommend you avoid checking generated files into version control. This permits a state where +the generated files in the repository were created from sources that were never checked in, +making it impossible to reproduce the current state. Also, your changes will effectively appear +twice in code reviews, with the generated version inscrutible by the reviewer. + +In TypeScript 1.8, the generated sources will have to be written alongside your originals, +so set `genDir` to the same location as your files (typicially the same as `rootDir`). +Add `**/*.ngfactory.ts` to your `.gitignore` or other mechanism for your version control system. + +In TypeScript 1.9 and above, you can add a generated folder into your application, +such as `codegen`. Using the `rootDirs` option, you can allow relative imports like +`import {} from './foo.ngfactory'` even though the `src` and `codegen` trees are distinct. +Add `**/codegen` to your `.gitignore` or similar. + +Note that in the second option, TypeScript will emit the code into two parallel directories +as well. This is by design, see https://github.com/Microsoft/TypeScript/issues/8245. +This makes the configuration of your runtime module loader more complex, so we don't recommend +this option yet. + +See the example in the `test/` directory for a working example. + +## Compiler CLI + +This program mimics the TypeScript tsc command line. It accepts a `-p` flag which points to a +`tsconfig.json` file, or a directory containing one. + +This CLI is intended for demos, prototyping, or for users with simple build systems +that run bare `tsc`. + +Users with a build system should expect an Angular 2 template plugin. Such a plugin would be +based on the `index.ts` in this directory, but should share the TypeScript compiler instance +with the one already used in the plugin for TypeScript typechecking and emit. + +## Design +At a high level, this program +- collects static metadata about the sources using the `ts-metadata-collector` package in angular2 +- uses the `OfflineCompiler` from `angular2/src/compiler/compiler` to codegen additional `.ts` files +- these `.ts` files are written to the `genDir` path, then compiled together with the application. + +## For developers +Run the compiler from source: +``` +# Build angular2 +gulp build.js.cjs +# Build the compiler +./node_modules/.bin/tsc -p tools/compiler_cli/src +# Run it on the test project +node ./dist/js/cjs/compiler_cli -p tools/compiler_cli/test +``` diff --git a/tools/compiler_cli/src/codegen.ts b/tools/compiler_cli/src/codegen.ts new file mode 100644 index 0000000000000..d8d9392fa1af4 --- /dev/null +++ b/tools/compiler_cli/src/codegen.ts @@ -0,0 +1,142 @@ +/** + * Transform template html and css into executable code. + * Intended to be used in a build step. + */ +import * as ts from 'typescript'; +import * as path from 'path'; + +import * as compiler from 'angular2/compiler'; +import {StaticReflector} from 'angular2/src/compiler/static_reflector'; +import {CompileMetadataResolver} from 'angular2/src/compiler/metadata_resolver'; +import {HtmlParser} from 'angular2/src/compiler/html_parser'; +import {DirectiveNormalizer} from 'angular2/src/compiler/directive_normalizer'; +import {Lexer} from 'angular2/src/compiler/expression_parser/lexer'; +import {Parser} from 'angular2/src/compiler/expression_parser/parser'; +import {TemplateParser} from 'angular2/src/compiler/template_parser'; +import {DomElementSchemaRegistry} from 'angular2/src/compiler/schema/dom_element_schema_registry'; +import {StyleCompiler} from 'angular2/src/compiler/style_compiler'; +import {ViewCompiler} from 'angular2/src/compiler/view_compiler/view_compiler'; +import {TypeScriptEmitter} from 'angular2/src/compiler/output/ts_emitter'; +import {RouterLinkTransform} from 'angular2/src/router/directives/router_link_transform'; +import {Parse5DomAdapter} from 'angular2/platform/server'; + +import {MetadataCollector} from 'ts-metadata-collector'; +import {NodeReflectorHost} from './reflector_host'; +import {wrapCompilerHost, CodeGeneratorHost} from './compiler_host'; + +const SOURCE_EXTENSION = /\.[jt]s$/; +const PREAMBLE = `/** + * This file is generated by the Angular 2 template compiler. + * Do not edit. + */ +`; + +export interface AngularCompilerOptions { + // Absolute path to a directory where generated file structure is written + genDir: string; +} + +export class CodeGenerator { + constructor(private ngOptions: AngularCompilerOptions, private basePath: string, + public program: ts.Program, public host: CodeGeneratorHost, + private staticReflector: StaticReflector, private resolver: CompileMetadataResolver, + private compiler: compiler.OfflineCompiler, + private reflectorHost: NodeReflectorHost) {} + + private generateSource(metadatas: compiler.CompileDirectiveMetadata[]) { + const normalize = (metadata: compiler.CompileDirectiveMetadata) => { + const directiveType = metadata.type.runtime; + const directives = this.resolver.getViewDirectivesMetadata(directiveType); + const pipes = this.resolver.getViewPipesMetadata(directiveType); + return new compiler.NormalizedComponentWithViewDirectives(metadata, directives, pipes); + }; + + return this.compiler.compileTemplates(metadatas.map(normalize)); + } + + private readComponents(absSourcePath: string) { + const result: Promise[] = []; + const metadata = this.staticReflector.getModuleMetadata(absSourcePath); + if (!metadata) { + console.log(`WARNING: no metadata found for ${absSourcePath}`); + return result; + } + + const symbols = Object.keys(metadata['metadata']); + if (!symbols || !symbols.length) { + return result; + } + for (const symbol of symbols) { + const staticType = this.reflectorHost.findDeclaration(absSourcePath, symbol, absSourcePath); + let directive: compiler.CompileDirectiveMetadata; + directive = this.resolver.maybeGetDirectiveMetadata(staticType); + + if (!directive || !directive.isComponent) { + continue; + } + result.push(this.compiler.normalizeDirectiveMetadata(directive)); + } + return result; + } + + codegen() { + Parse5DomAdapter.makeCurrent(); + const generateOneFile = (absSourcePath: string) => + Promise.all(this.readComponents(absSourcePath)) + .then((metadatas: compiler.CompileDirectiveMetadata[]) => { + if (!metadatas || !metadatas.length) { + return; + } + const generated = this.generateSource(metadatas); + const sourceFile = this.program.getSourceFile(absSourcePath); + + // Write codegen in a directory structure matching the sources. + // TODO(alexeagle): maybe use generated.moduleUrl instead of hardcoded ".ngfactory.ts" + // TODO(alexeagle): relativize paths by the rootDirs option + const emitPath = + path.join(this.ngOptions.genDir, path.relative(this.basePath, absSourcePath)) + .replace(SOURCE_EXTENSION, '.ngfactory.ts'); + this.host.writeFile(emitPath, PREAMBLE + generated.source, false, () => {}, + [sourceFile]); + }) + .catch((e) => { console.error(e.stack); }); + + return Promise.all(this.program.getRootFileNames() + .filter(f => !/\.ngfactory\.ts$/.test(f)) + .map(generateOneFile)); + } + + static create(ngOptions: AngularCompilerOptions, parsed: ts.ParsedCommandLine, basePath: string, + compilerHost: ts.CompilerHost): + {errors?: ts.Diagnostic[], generator?: CodeGenerator} { + const program = ts.createProgram(parsed.fileNames, parsed.options, compilerHost); + const errors = program.getOptionsDiagnostics(); + if (errors && errors.length) { + return {errors}; + } + + const metadataCollector = new MetadataCollector(); + const reflectorHost = + new NodeReflectorHost(program, metadataCollector, compilerHost, parsed.options); + const xhr: compiler.XHR = {get: (s: string) => Promise.resolve(compilerHost.readFile(s))}; + const urlResolver: compiler.UrlResolver = compiler.createOfflineCompileUrlResolver(); + const staticReflector = new StaticReflector(reflectorHost); + const htmlParser = new HtmlParser(); + const normalizer = new DirectiveNormalizer(xhr, urlResolver, htmlParser); + const parser = new Parser(new Lexer()); + const tmplParser = new TemplateParser(parser, new DomElementSchemaRegistry(), htmlParser, + /*console*/ null, [new RouterLinkTransform(parser)]); + const offlineCompiler = new compiler.OfflineCompiler( + normalizer, tmplParser, new StyleCompiler(urlResolver), + new ViewCompiler(new compiler.CompilerConfig(true, true, true)), new TypeScriptEmitter()); + const resolver = new CompileMetadataResolver( + new compiler.DirectiveResolver(staticReflector), new compiler.PipeResolver(staticReflector), + new compiler.ViewResolver(staticReflector), null, null, staticReflector); + + return { + generator: new CodeGenerator(ngOptions, basePath, program, + wrapCompilerHost(compilerHost, parsed.options), staticReflector, + resolver, offlineCompiler, reflectorHost) + }; + } +} diff --git a/tools/compiler_cli/src/compiler_host.ts b/tools/compiler_cli/src/compiler_host.ts new file mode 100644 index 0000000000000..283bf3792016c --- /dev/null +++ b/tools/compiler_cli/src/compiler_host.ts @@ -0,0 +1,63 @@ +import * as ts from 'typescript'; +import * as path from 'path'; +import {convertDecorators} from 'tsickle'; + +const DEBUG = false; +function debug(msg: string, ...o: any[]) { + if (DEBUG) console.log(msg, ...o); +} + +/** + * Implementation of CompilerHost that forwards all methods to another instance. + * Useful for partial implementations to override only methods they care about. + */ +export abstract class DelegatingHost implements ts.CompilerHost { + constructor(protected delegate: ts.CompilerHost) {} + getSourceFile = + (fileName: string, languageVersion: ts.ScriptTarget, onError?: (message: string) => void) => + this.delegate.getSourceFile(fileName, languageVersion, onError); + + getCancellationToken = () => this.delegate.getCancellationToken(); + getDefaultLibFileName = (options: ts.CompilerOptions) => + this.delegate.getDefaultLibFileName(options); + getDefaultLibLocation = () => this.delegate.getDefaultLibLocation(); + writeFile: ts.WriteFileCallback = this.delegate.writeFile; + getCurrentDirectory = () => this.delegate.getCurrentDirectory(); + getCanonicalFileName = (fileName: string) => this.delegate.getCanonicalFileName(fileName); + useCaseSensitiveFileNames = () => this.delegate.useCaseSensitiveFileNames(); + getNewLine = () => this.delegate.getNewLine(); + fileExists = (fileName: string) => this.delegate.fileExists(fileName); + readFile = (fileName: string) => this.delegate.readFile(fileName); + trace = (s: string) => this.delegate.trace(s); + directoryExists = (directoryName: string) => this.delegate.directoryExists(directoryName); +} +const TSICKLE_SUPPORT = `interface DecoratorInvocation { + type: Function; + args?: any[]; +} +`; +export class CodeGeneratorHost extends DelegatingHost { + // Additional diagnostics gathered by pre- and post-emit transformations. + public diagnostics: ts.Diagnostic[] = []; + constructor(delegate: ts.CompilerHost, private options: ts.CompilerOptions) { super(delegate); } + + getSourceFile = + (fileName: string, languageVersion: ts.ScriptTarget, onError?: (message: string) => void) => { + const originalContent = this.delegate.readFile(fileName); + let newContent = originalContent; + if (!/\.d\.ts$/.test(fileName)) { + const converted = convertDecorators(fileName, originalContent); + if (converted.diagnostics) { + this.diagnostics.push(...converted.diagnostics); + } + newContent = TSICKLE_SUPPORT + converted.output; + debug(newContent); + } + return ts.createSourceFile(fileName, newContent, languageVersion, true); + } +} + +export function wrapCompilerHost(delegate: ts.CompilerHost, + options: ts.CompilerOptions): CodeGeneratorHost { + return new CodeGeneratorHost(delegate, options); +} \ No newline at end of file diff --git a/tools/compiler_cli/src/index.ts b/tools/compiler_cli/src/index.ts new file mode 100644 index 0000000000000..acc0f9c948ea8 --- /dev/null +++ b/tools/compiler_cli/src/index.ts @@ -0,0 +1,55 @@ +// TODO(alexeagle): use --lib=node when available; remove this reference +// https://github.com/Microsoft/TypeScript/pull/7757#issuecomment-205644657 +/// + +// Must be imported first, because angular2 decorators throws on load. +import 'reflect-metadata'; + +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; +import {tsc, check} from './tsc'; + +import {CodeGenerator} from './codegen'; + +const DEBUG = false; + +function debug(msg: string, ...o: any[]) { + if (DEBUG) console.log(msg, ...o); +} + +export function main(project: string, basePath?: string): Promise { + // file names in tsconfig are resolved relative to this absolute path + basePath = path.join(process.cwd(), basePath || project); + + // read the configuration options from wherever you store them + const {parsed, ngOptions} = tsc.readConfiguration(project, basePath); + + const host = ts.createCompilerHost(parsed.options, true); + const {errors, generator} = CodeGenerator.create(ngOptions, parsed, basePath, host); + check(errors); + + return generator.codegen() + // use our compiler host, which wraps the built-in one from TypeScript + // This allows us to add features like --stripDesignTimeDecorators to optimize your + // application more. + .then(() => tsc.typeCheckAndEmit(generator.host, generator.program)) + .catch(rejected => { + console.error('Compile failed\n', rejected.message); + throw new Error(rejected); + }); +} + +// CLI entry point +if (require.main === module) { + const args = require('minimist')(process.argv.slice(2)); + try { + main(args.p || args.project || '.', args.basePath) + .then(exitCode => process.exit(exitCode)) + .catch(r => { process.exit(1); }); + } catch (e) { + console.error(e.stack); + console.error("Compilation failed"); + process.exit(1); + } +} diff --git a/tools/compiler_cli/src/reflector_host.ts b/tools/compiler_cli/src/reflector_host.ts new file mode 100644 index 0000000000000..ac7aa47c4de7f --- /dev/null +++ b/tools/compiler_cli/src/reflector_host.ts @@ -0,0 +1,147 @@ +import {StaticReflectorHost, StaticSymbol} from 'angular2/src/compiler/static_reflector'; +import * as ts from 'typescript'; +import {MetadataCollector, ModuleMetadata} from 'ts-metadata-collector'; +import * as fs from 'fs'; +import * as path from 'path'; + +const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/; +const DTS = /\.d\.ts$/; + +export class NodeReflectorHost implements StaticReflectorHost { + constructor(private program: ts.Program, private metadataCollector: MetadataCollector, + private compilerHost: ts.CompilerHost, private options: ts.CompilerOptions) {} + + private resolve(m: string, containingFile: string) { + const resolved = + ts.resolveModuleName(m, containingFile, this.options, this.compilerHost).resolvedModule; + return resolved ? resolved.resolvedFileName : null + }; + + /** + * We want a moduleId that will appear in import statements in the generated code. + * These need to be in a form that system.js can load, so absolute file paths don't work. + * Relativize the paths by checking candidate prefixes of the absolute path, to see if + * they are resolvable by the moduleResolution strategy from the CompilerHost. + */ + private getModuleId(declarationFile: string, containingFile: string) { + const parts = declarationFile.replace(EXT, '').split(path.sep); + + for (let index = parts.length - 1; index >= 0; index--) { + let candidate = parts.slice(index, parts.length).join(path.sep); + if (this.resolve(candidate, containingFile) === declarationFile) { + let pkg = parts[index]; + let pkgPath = parts.slice(index + 1, parts.length).join(path.sep); + return `asset:${pkg}/lib/${pkgPath}`; + } + } + for (let index = parts.length - 1; index >= 0; index--) { + let candidate = parts.slice(index, parts.length).join(path.sep); + if (this.resolve('.' + path.sep + candidate, containingFile) === declarationFile) { + return `asset:./lib/${candidate}`; + } + } + throw new Error( + `Unable to find any resolvable import for ${declarationFile} relative to ${containingFile}`); + } + + findDeclaration(module: string, symbolName: string, containingFile: string, + containingModule?: string): StaticSymbol { + if (!containingFile || !containingFile.length) { + if (module.indexOf(".") === 0) { + throw new Error("Resolution of relative paths requires a containing file."); + } + // Any containing file gives the same result for absolute imports + containingFile = 'index.ts'; + } + + try { + const filePath = this.resolve(module, containingFile); + + if (!filePath) { + throw new Error(`Could not resolve module ${module} relative to ${containingFile}`); + } + + const tc = this.program.getTypeChecker(); + const sf = this.program.getSourceFile(filePath); + + let symbol = tc.getExportsOfModule((sf).symbol).find(m => m.name === symbolName); + if (!symbol) { + throw new Error(`can't find symbol ${symbolName} exported from module ${filePath}`); + } + while (symbol && + symbol.flags & ts.SymbolFlags.Alias) { // This is an alias, follow what it aliases + symbol = tc.getAliasedSymbol(symbol); + } + const declaration = symbol.getDeclarations()[0]; + const declarationFile = declaration.getSourceFile().fileName; + const moduleId = this.getModuleId(declarationFile, containingFile); + + return this.getStaticSymbol(moduleId, declarationFile, symbol.getName()); + } catch (e) { + console.error(`can't resolve module ${module} from ${containingFile}`); + throw e; + } + } + + private typeCache = new Map(); + + /** + * getStaticSymbol produces a Type whose metadata is known but whose implementation is not loaded. + * All types passed to the StaticResolver should be pseudo-types returned by this method. + * + * @param moduleId the module identifier as an absolute path. + * @param declarationFile the absolute path of the file where the symbol is declared + * @param name the name of the type. + */ + getStaticSymbol(moduleId: string, declarationFile: string, name: string): StaticSymbol { + let key = `"${declarationFile}".${name}`; + let result = this.typeCache.get(key); + if (!result) { + result = new StaticSymbol(moduleId, declarationFile, name); + this.typeCache.set(key, result); + } + return result; + } + + // TODO(alexeagle): take a statictype + getMetadataFor(filePath: string): ModuleMetadata { + if (!fs.existsSync(filePath)) { + throw new Error(`No such file '${filePath}'`); + } + if (DTS.test(filePath)) { + const metadataPath = filePath.replace(DTS, '.metadata.json'); + if (fs.existsSync(metadataPath)) { + return this.readMetadata(metadataPath); + } + } + + let sf = this.program.getSourceFile(filePath); + if (!sf) { + throw new Error(`Source file ${filePath} not present in program.`); + } + const metadata = this.metadataCollector.getMetadata(sf, this.program.getTypeChecker()); + return metadata; + } + + readMetadata(filePath: string) { + try { + const result = JSON.parse(fs.readFileSync(filePath, {encoding: 'utf-8'})); + return result; + } catch (e) { + console.error(`Failed to read JSON file ${filePath}`); + throw e; + } + } + + writeMetadata(emitFilePath: string, sourceFile: ts.SourceFile) { + if (DTS.test(emitFilePath)) { + const path = emitFilePath.replace(DTS, '.metadata.json'); + const metadata = + this.metadataCollector.getMetadata(sourceFile, this.program.getTypeChecker()); + if (metadata && metadata.metadata) { + const metadataText = JSON.stringify(metadata); + fs.writeFileSync(path, metadataText, {encoding: 'utf-8'}); + } + } + } +} diff --git a/tools/compiler_cli/src/tsc.ts b/tools/compiler_cli/src/tsc.ts new file mode 100644 index 0000000000000..9179147470608 --- /dev/null +++ b/tools/compiler_cli/src/tsc.ts @@ -0,0 +1,98 @@ +import * as ts from 'typescript'; +// Don't import from fs in general, that's the CompilerHost's job +import {lstatSync} from 'fs'; +import * as path from 'path'; +import {AngularCompilerOptions} from './codegen'; +import {CodeGeneratorHost} from './compiler_host'; + +/** + * Our interface to the TypeScript standard compiler. + * If you write an Angular compiler plugin for another build tool, + * you should implement a similar interface. + */ +export interface CompilerInterface { + readConfiguration( + project: string, + basePath: string): {parsed: ts.ParsedCommandLine, ngOptions: AngularCompilerOptions}; + typeCheckAndEmit(compilerHost: CodeGeneratorHost, oldProgram?: ts.Program): number; +} + +const DEBUG = false; +const SOURCE_EXTENSION = /\.[jt]s$/; + +function debug(msg: string, ...o: any[]) { + if (DEBUG) console.log(msg, ...o); +} + +export function formatDiagnostics(diags: ts.Diagnostic[]): string { + return diags.map((d) => { + let res = ts.DiagnosticCategory[d.category]; + if (d.file) { + res += ' at ' + d.file.fileName + ':'; + const {line, character} = d.file.getLineAndCharacterOfPosition(d.start); + res += (line + 1) + ':' + (character + 1) + ':'; + } + res += ' ' + ts.flattenDiagnosticMessageText(d.messageText, '\n'); + return res; + }) + .join('\n'); +} + +export function check(diags: ts.Diagnostic[]) { + if (diags && diags.length && diags[0]) { + throw new Error(formatDiagnostics(diags)); + } +} + +export class Tsc implements CompilerInterface { + public ngOptions: AngularCompilerOptions; + public parsed: ts.ParsedCommandLine; + private basePath: string; + + readConfiguration(project: string, basePath: string) { + this.basePath = basePath; + + // Allow a directory containing tsconfig.json as the project value + if (lstatSync(project).isDirectory()) { + project = path.join(project, "tsconfig.json"); + } + + const {config, error} = ts.readConfigFile(project, ts.sys.readFile); + check([error]); + + this.parsed = + ts.parseJsonConfigFileContent(config, {readDirectory: ts.sys.readDirectory}, basePath); + + check(this.parsed.errors); + + // Default codegen goes to the current directory + // Parsed options are already converted to absolute paths + this.ngOptions = config.angularCompilerOptions || {}; + this.ngOptions.genDir = path.join(basePath, this.ngOptions.genDir || '.'); + return {parsed: this.parsed, ngOptions: this.ngOptions}; + } + + typeCheckAndEmit(compilerHost: CodeGeneratorHost, oldProgram?: ts.Program): number { + const program = + ts.createProgram(this.parsed.fileNames, this.parsed.options, compilerHost, oldProgram); + debug("Checking global diagnostics..."); + check(program.getGlobalDiagnostics()); + + debug("Type checking..."); + { + let diagnostics: ts.Diagnostic[] = []; + for (let sf of program.getSourceFiles()) { + diagnostics.push(...ts.getPreEmitDiagnostics(program, sf)); + } + check(diagnostics); + } + + debug("Emitting outputs..."); + + const {diagnostics, emitSkipped} = program.emit(); + check(diagnostics); + check(compilerHost.diagnostics); + return emitSkipped ? 1 : 0; + } +} +export var tsc: CompilerInterface = new Tsc(); diff --git a/tools/compiler_cli/src/tsconfig.json b/tools/compiler_cli/src/tsconfig.json new file mode 100644 index 0000000000000..ae6610871fee2 --- /dev/null +++ b/tools/compiler_cli/src/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": ["es6", "dom"], + "noImplicitAny": true, + "sourceMap": true, + "baseUrl": "../../..", + "paths": { + "angular2/*": ["dist/js/cjs/angular2/*"], + "ts-metadata-collector": ["dist/tools/metadata"] + }, + "experimentalDecorators": true, + "rootDir": ".", + // Write to a directory that has the node_modules symlink in a parent + "outDir": "../../../dist/js/cjs/compiler_cli", + "declaration": true + }, + "exclude": ["test"] +} diff --git a/tools/compiler_cli/test/src/a/multiple_components.html b/tools/compiler_cli/test/src/a/multiple_components.html new file mode 100644 index 0000000000000..281c6866c375f --- /dev/null +++ b/tools/compiler_cli/test/src/a/multiple_components.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/tools/compiler_cli/test/src/a/multiple_components.ts b/tools/compiler_cli/test/src/a/multiple_components.ts new file mode 100644 index 0000000000000..8cd541bf107be --- /dev/null +++ b/tools/compiler_cli/test/src/a/multiple_components.ts @@ -0,0 +1,21 @@ +import {Component} from 'angular2/core'; + +@Component({ + selector: 'my-comp', + template: '
', +}) +export class MyComp { +} + +@Component({ + selector: 'next-comp', + templateUrl: './multiple_components.html', +}) +export class NextComp { +} + +// Verify that exceptions from DirectiveResolver don't propagate +export function NotADirective(c: any): void {} +@NotADirective +export class HasCustomDecorator { +} diff --git a/tools/compiler_cli/test/src/basic.html b/tools/compiler_cli/test/src/basic.html new file mode 100644 index 0000000000000..1db443fdd6135 --- /dev/null +++ b/tools/compiler_cli/test/src/basic.html @@ -0,0 +1,2 @@ +
{{ctxProp}}
+
diff --git a/tools/compiler_cli/test/src/basic.ts b/tools/compiler_cli/test/src/basic.ts new file mode 100644 index 0000000000000..55dccdae5ab88 --- /dev/null +++ b/tools/compiler_cli/test/src/basic.ts @@ -0,0 +1,14 @@ +import {Component, Injectable} from 'angular2/core'; +import {FORM_DIRECTIVES} from 'angular2/common'; +import {MyComp} from './a/multiple_components'; + +@Component({ + selector: 'basic', + templateUrl: './basic.html', + directives: [MyComp, FORM_DIRECTIVES], +}) +@Injectable() +export class Basic { + ctxProp: string; + constructor() { this.ctxProp = 'initial value'; } +} diff --git a/tools/compiler_cli/test/src/bootstrap.ts b/tools/compiler_cli/test/src/bootstrap.ts new file mode 100644 index 0000000000000..c73476413916d --- /dev/null +++ b/tools/compiler_cli/test/src/bootstrap.ts @@ -0,0 +1,8 @@ +import {coreBootstrap, ReflectiveInjector} from 'angular2/core'; +import {browserPlatform, BROWSER_APP_PROVIDERS} from 'angular2/platform/browser'; +import {BasicNgFactory} from './basic.ngfactory'; +import {Basic} from './basic'; + +const appInjector = + ReflectiveInjector.resolveAndCreate(BROWSER_APP_PROVIDERS, browserPlatform().injector); +coreBootstrap(appInjector, BasicNgFactory); diff --git a/tools/compiler_cli/test/src/dep.d.ts b/tools/compiler_cli/test/src/dep.d.ts new file mode 100644 index 0000000000000..6bb9451e37b50 --- /dev/null +++ b/tools/compiler_cli/test/src/dep.d.ts @@ -0,0 +1,2 @@ +// Verify we don't try to extract metadata for .d.ts files +export declare var a: string; diff --git a/tools/compiler_cli/test/tsconfig.json b/tools/compiler_cli/test/tsconfig.json new file mode 100644 index 0000000000000..13d338a39858f --- /dev/null +++ b/tools/compiler_cli/test/tsconfig.json @@ -0,0 +1,27 @@ +{ + "angularCompilerOptions": { + // For TypeScript 1.8, we have to lay out generated files + // in the same source directory with your code. + "genDir": "." + }, + + "compilerOptions": { + "target": "es6", + "experimentalDecorators": true, + "noImplicitAny": false, + "moduleResolution": "node", + "outDir": "../../../dist/tools/compiler_cli/test/built", + "rootDir": "src", + + /** + * These options are only needed because the test depends + * on locally-built sources, not NPM distributions. + */ + "baseUrl": "../../..", + "paths": { + "angular2/*": ["dist/js/cjs/angular2/*"], + "rxjs/*": ["node_modules/rxjs/*"], + "ts-metadata-collector": ["dist/tools/metadata"] + } + } +}