From cf7d47dda01dffdc9e32e9baa753be436fe5851e Mon Sep 17 00:00:00 2001 From: Chuck Jazdzewski Date: Fri, 18 Aug 2017 14:03:59 -0700 Subject: [PATCH] feat(compiler-cli): add watch mode to `ngc` (#18818) With this change ngc now accepts a `-w` or a `--watch` command-line option that will automatically perform a recompile whenever any source files change on disk. PR Close #18818 --- npm-shrinkwrap.clean.json | 3 + npm-shrinkwrap.json | 5 + package.json | 1 + packages/compiler-cli/index.ts | 2 +- packages/compiler-cli/package.json | 3 +- .../src/diagnostics/check_types.ts | 8 +- packages/compiler-cli/src/main.ts | 24 +- ...{perform-compile.ts => perform_compile.ts} | 60 +++-- packages/compiler-cli/src/perform_watch.ts | 223 ++++++++++++++++++ packages/compiler-cli/src/transformers/api.ts | 8 +- .../compiler-cli/src/transformers/program.ts | 37 ++- .../test/diagnostics/check_types_spec.ts | 9 +- .../compiler-cli/test/diagnostics/mocks.ts | 2 +- packages/compiler-cli/test/main_spec.ts | 155 +++++++++++- packages/compiler/src/aot/compiler.ts | 29 ++- .../src/aot/static_symbol_resolver.ts | 18 ++ packages/compiler/src/i18n/extractor.ts | 4 +- .../language-service/src/typescript_host.ts | 2 +- 18 files changed, 539 insertions(+), 54 deletions(-) rename packages/compiler-cli/src/{perform-compile.ts => perform_compile.ts} (76%) create mode 100644 packages/compiler-cli/src/perform_watch.ts diff --git a/npm-shrinkwrap.clean.json b/npm-shrinkwrap.clean.json index 7241649e63..7e6140e441 100644 --- a/npm-shrinkwrap.clean.json +++ b/npm-shrinkwrap.clean.json @@ -275,6 +275,9 @@ "@types/base64-js": { "version": "1.2.5" }, + "@types/chokidar": { + "version": "1.7.2" + }, "@types/fs-extra": { "version": "0.0.22-alpha" }, diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index eccd03819b..641f10ca15 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -445,6 +445,11 @@ "from": "@types/base64-js@latest", "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.2.5.tgz" }, + "@types/chokidar": { + "version": "1.7.2", + "from": "@types/chokidar@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/@types/chokidar/-/chokidar-1.7.2.tgz" + }, "@types/fs-extra": { "version": "0.0.22-alpha", "from": "@types/fs-extra@latest", diff --git a/package.json b/package.json index 4577e44ed3..76a7ce7453 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@bazel/typescript": "0.0.7", "@types/angularjs": "^1.5.13-alpha", "@types/base64-js": "^1.2.5", + "@types/chokidar": "^1.1.0", "@types/fs-extra": "0.0.22-alpha", "@types/hammerjs": "^2.0.33", "@types/jasmine": "^2.2.22-alpha", diff --git a/packages/compiler-cli/index.ts b/packages/compiler-cli/index.ts index e7095d0698..1560c79b3d 100644 --- a/packages/compiler-cli/index.ts +++ b/packages/compiler-cli/index.ts @@ -20,7 +20,7 @@ export {BuiltinType, DeclarationKind, Definition, PipeInfo, Pipes, Signature, Sp export * from './src/transformers/api'; export * from './src/transformers/entry_points'; -export {performCompilation, readConfiguration, formatDiagnostics, calcProjectFileAndBasePath, createNgCompilerOptions} from './src/perform-compile'; +export {performCompilation, readConfiguration, formatDiagnostics, calcProjectFileAndBasePath, createNgCompilerOptions} from './src/perform_compile'; // TODO(hansl): moving to Angular 4 need to update this API. export {NgTools_InternalApi_NG_2 as __NGTOOLS_PRIVATE_API_2} from './src/ngtools_api'; diff --git a/packages/compiler-cli/package.json b/packages/compiler-cli/package.json index c970f22eab..ed05d98bfe 100644 --- a/packages/compiler-cli/package.json +++ b/packages/compiler-cli/package.json @@ -12,7 +12,8 @@ "@angular/tsc-wrapped": "5.0.0-beta.5", "reflect-metadata": "^0.1.2", "minimist": "^1.2.0", - "tsickle": "^0.23.4" + "tsickle": "^0.23.4", + "chokidar": "^1.4.2" }, "peerDependencies": { "typescript": "^2.0.2", diff --git a/packages/compiler-cli/src/diagnostics/check_types.ts b/packages/compiler-cli/src/diagnostics/check_types.ts index f71fd85ea3..4ecb9e8974 100644 --- a/packages/compiler-cli/src/diagnostics/check_types.ts +++ b/packages/compiler-cli/src/diagnostics/check_types.ts @@ -9,7 +9,7 @@ import {AotCompiler, AotCompilerHost, AotCompilerOptions, EmitterVisitorContext, GeneratedFile, NgAnalyzedModules, ParseSourceSpan, Statement, StaticReflector, TypeScriptEmitter, createAotCompiler} from '@angular/compiler'; import * as ts from 'typescript'; -import {Diagnostic} from '../transformers/api'; +import {DEFAULT_ERROR_CODE, Diagnostic, SOURCE} from '../transformers/api'; interface FactoryInfo { source: ts.SourceFile; @@ -142,8 +142,10 @@ export class TypeChecker { const fileName = span.start.file.url; const diagnosticsList = diagnosticsFor(fileName); diagnosticsList.push({ - message: diagnosticMessageToString(diagnostic.messageText), - category: diagnostic.category, span + messageText: diagnosticMessageToString(diagnostic.messageText), + category: diagnostic.category, span, + source: SOURCE, + code: DEFAULT_ERROR_CODE }); } } diff --git a/packages/compiler-cli/src/main.ts b/packages/compiler-cli/src/main.ts index 60b989bb42..3c67b1881f 100644 --- a/packages/compiler-cli/src/main.ts +++ b/packages/compiler-cli/src/main.ts @@ -17,14 +17,19 @@ import * as path from 'path'; import * as tsickle from 'tsickle'; import * as api from './transformers/api'; import * as ngc from './transformers/entry_points'; -import {performCompilation, readConfiguration, formatDiagnostics, Diagnostics, ParsedConfiguration} from './perform-compile'; +import {calcProjectFileAndBasePath, exitCodeFromResult, performCompilation, readConfiguration, formatDiagnostics, Diagnostics, ParsedConfiguration, PerformCompilationResult} from './perform_compile'; +import {performWatchCompilation, createPerformWatchHost} from './perform_watch'; import {isSyntaxError} from '@angular/compiler'; import {CodeGenerator} from './codegen'; export function main( args: string[], consoleError: (s: string) => void = console.error): Promise { const parsedArgs = require('minimist')(args); + if (parsedArgs.w || parsedArgs.watch) { + const result = watchMode(parsedArgs, consoleError); + return Promise.resolve(exitCodeFromResult(result.firstCompileResult)); + } const {rootNames, options, errors: configErrors} = readCommandLineAndConfiguration(parsedArgs); if (configErrors.length) { return Promise.resolve(reportErrorsAndExit(options, configErrors, consoleError)); @@ -83,12 +88,16 @@ function createEmitCallback(options: api.CompilerOptions): api.TsEmitCallback { }); } +function projectOf(args: any): string { + return (args && (args.p || args.project)) || '.'; +} + function readCommandLineAndConfiguration(args: any): ParsedConfiguration { - const project = args.p || args.project || '.'; + const project = projectOf(args); const allDiagnostics: Diagnostics = []; const config = readConfiguration(project); const options = mergeCommandLineParams(args, config.options); - return {rootNames: config.rootNames, options, errors: config.errors}; + return {project, rootNames: config.rootNames, options, errors: config.errors}; } function reportErrorsAndExit( @@ -101,6 +110,15 @@ function reportErrorsAndExit( return exitCode; } +export function watchMode(args: any, consoleError: (s: string) => void) { + const project = projectOf(args); + const {projectFile, basePath} = calcProjectFileAndBasePath(project); + const config = readConfiguration(project); + return performWatchCompilation(createPerformWatchHost(projectFile, diagnostics => { + consoleError(formatDiagnostics(config.options, diagnostics)); + }, options => createEmitCallback(options))); +} + function mergeCommandLineParams( cliArgs: {[k: string]: string}, options: api.CompilerOptions): api.CompilerOptions { // TODO: also merge in tsc command line parameters by calling diff --git a/packages/compiler-cli/src/perform-compile.ts b/packages/compiler-cli/src/perform_compile.ts similarity index 76% rename from packages/compiler-cli/src/perform-compile.ts rename to packages/compiler-cli/src/perform_compile.ts index 2a81339840..f5337a8cf0 100644 --- a/packages/compiler-cli/src/perform-compile.ts +++ b/packages/compiler-cli/src/perform_compile.ts @@ -20,7 +20,7 @@ const TS_EXT = /\.ts$/; export type Diagnostics = Array; function isTsDiagnostic(diagnostic: any): diagnostic is ts.Diagnostic { - return diagnostic && (diagnostic.file || diagnostic.messageText); + return diagnostic && diagnostic.source != 'angular'; } export function formatDiagnostics(options: api.CompilerOptions, diags: Diagnostics): string { @@ -41,9 +41,9 @@ export function formatDiagnostics(options: api.CompilerOptions, diags: Diagnosti ` at ${d.span.start.file.url}(${d.span.start.line + 1},${d.span.start.col + 1})`; } if (d.span && d.span.details) { - res += `: ${d.span.details}, ${d.message}\n`; + res += `: ${d.span.details}, ${d.messageText}\n`; } else { - res += `: ${d.message}\n`; + res += `: ${d.messageText}\n`; } return res; } @@ -54,6 +54,7 @@ export function formatDiagnostics(options: api.CompilerOptions, diags: Diagnosti } export interface ParsedConfiguration { + project: string; options: api.CompilerOptions; rootNames: string[]; errors: Diagnostics; @@ -81,7 +82,7 @@ export function readConfiguration( let {config, error} = ts.readConfigFile(projectFile, ts.sys.readFile); if (error) { - return {errors: [error], rootNames: [], options: {}}; + return {project, errors: [error], rootNames: [], options: {}}; } const parseConfigHost = { useCaseSensitiveFileNames: true, @@ -94,16 +95,40 @@ export function readConfiguration( const rootNames = parsed.fileNames.map(f => path.normalize(f)); const options = createNgCompilerOptions(basePath, config, parsed.options); - return {rootNames, options, errors: parsed.errors}; + return {project: projectFile, rootNames, options, errors: parsed.errors}; } catch (e) { const errors: Diagnostics = [{ category: ts.DiagnosticCategory.Error, - message: e.stack, + messageText: e.stack, + source: api.SOURCE, + code: api.UNKNOWN_ERROR_CODE }]; - return {errors, rootNames: [], options: {}}; + return {project: '', errors, rootNames: [], options: {}}; } } +export interface PerformCompilationResult { + diagnostics: Diagnostics; + program?: api.Program; + emitResult?: ts.EmitResult; +} + +export function exitCodeFromResult(result: PerformCompilationResult | undefined): number { + if (!result) { + // If we didn't get a result we should return failure. + return 1; + } + if (!result.diagnostics || result.diagnostics.length === 0) { + // If we have a result and didn't get any errors, we succeeded. + return 0; + } + + // Return 2 if any of the errors were unknown. + return result.diagnostics.some(d => d.source === 'angular' && d.code === api.UNKNOWN_ERROR_CODE) ? + 2 : + 1; +} + export function performCompilation( {rootNames, options, host, oldProgram, emitCallback, customTransformers}: { rootNames: string[], @@ -112,11 +137,7 @@ export function performCompilation( oldProgram?: api.Program, emitCallback?: api.TsEmitCallback, customTransformers?: api.CustomTransformers - }): { - program?: api.Program, - emitResult?: ts.EmitResult, - diagnostics: Diagnostics, -} { + }): PerformCompilationResult { const [major, minor] = ts.version.split('.'); if (Number(major) < 2 || (Number(major) === 2 && Number(minor) < 3)) { @@ -168,19 +189,24 @@ export function performCompilation( ((options.skipMetadataEmit || options.flatModuleOutFile) ? 0 : api.EmitFlags.Metadata) }); allDiagnostics.push(...emitResult.diagnostics); + return {diagnostics: allDiagnostics, program, emitResult}; } + return {diagnostics: allDiagnostics, program}; } catch (e) { let errMsg: string; + let code: number; if (isSyntaxError(e)) { // don't report the stack for syntax errors as they are well known errors. errMsg = e.message; + code = api.DEFAULT_ERROR_CODE; } else { errMsg = e.stack; + // It is not a syntax error we might have a program with unknown state, discard it. + program = undefined; + code = api.UNKNOWN_ERROR_CODE; } - allDiagnostics.push({ - category: ts.DiagnosticCategory.Error, - message: errMsg, - }); + allDiagnostics.push( + {category: ts.DiagnosticCategory.Error, messageText: errMsg, code, source: api.SOURCE}); + return {diagnostics: allDiagnostics, program}; } - return {program, emitResult, diagnostics: allDiagnostics}; } \ No newline at end of file diff --git a/packages/compiler-cli/src/perform_watch.ts b/packages/compiler-cli/src/perform_watch.ts new file mode 100644 index 0000000000..40f7735f72 --- /dev/null +++ b/packages/compiler-cli/src/perform_watch.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as chokidar from 'chokidar'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import {Diagnostics, ParsedConfiguration, PerformCompilationResult, exitCodeFromResult, performCompilation, readConfiguration} from './perform_compile'; +import * as api from './transformers/api'; +import {createCompilerHost} from './transformers/entry_points'; + +const ChangeDiagnostics = { + Compilation_complete_Watching_for_file_changes: { + category: ts.DiagnosticCategory.Message, + messageText: 'Compilation complete. Watching for file changes.', + code: api.DEFAULT_ERROR_CODE, + source: api.SOURCE + }, + Compilation_failed_Watching_for_file_changes: { + category: ts.DiagnosticCategory.Message, + messageText: 'Compilation failed. Watching for file changes.', + code: api.DEFAULT_ERROR_CODE, + source: api.SOURCE + }, + File_change_detected_Starting_incremental_compilation: { + category: ts.DiagnosticCategory.Message, + messageText: 'File change detected. Starting incremental compilation.', + code: api.DEFAULT_ERROR_CODE, + source: api.SOURCE + }, +}; + +export enum FileChangeEvent { + Change, + CreateDelete +} + +export interface PerformWatchHost { + reportDiagnostics(diagnostics: Diagnostics): void; + readConfiguration(): ParsedConfiguration; + createCompilerHost(options: api.CompilerOptions): api.CompilerHost; + createEmitCallback(options: api.CompilerOptions): api.TsEmitCallback|undefined; + onFileChange(listener: (event: FileChangeEvent, fileName: string) => void): + {close: () => void, ready: (cb: () => void) => void}; + setTimeout(callback: () => void, ms: number): any; + clearTimeout(timeoutId: any): void; +} + +export function createPerformWatchHost( + configFileName: string, reportDiagnostics: (diagnostics: Diagnostics) => void, + createEmitCallback?: (options: api.CompilerOptions) => api.TsEmitCallback): PerformWatchHost { + return { + reportDiagnostics: reportDiagnostics, + createCompilerHost: options => createCompilerHost({options}), + readConfiguration: () => readConfiguration(configFileName), + createEmitCallback: options => createEmitCallback ? createEmitCallback(options) : undefined, + onFileChange: (listeners) => { + const parsed = readConfiguration(configFileName); + function stubReady(cb: () => void) { process.nextTick(cb); } + if (parsed.errors && parsed.errors.length) { + reportDiagnostics(parsed.errors); + return {close: () => {}, ready: stubReady}; + } + if (!parsed.options.basePath) { + reportDiagnostics([{ + category: ts.DiagnosticCategory.Error, + messageText: 'Invalid configuration option. baseDir not specified', + source: api.SOURCE, + code: api.DEFAULT_ERROR_CODE + }]); + return {close: () => {}, ready: stubReady}; + } + const watcher = chokidar.watch(parsed.options.basePath, { + // ignore .dotfiles, .js and .map files. + // can't ignore other files as we e.g. want to recompile if an `.html` file changes as well. + ignored: /((^[\/\\])\..)|(\.js$)|(\.map$)|(\.metadata\.json)/, + ignoreInitial: true, + persistent: true, + }); + watcher.on('all', (event: string, path: string) => { + switch (event) { + case 'change': + listeners(FileChangeEvent.Change, path); + break; + case 'unlink': + case 'add': + listeners(FileChangeEvent.CreateDelete, path); + break; + } + }); + function ready(cb: () => void) { watcher.on('ready', cb); } + return {close: () => watcher.close(), ready}; + }, + setTimeout: (ts.sys.clearTimeout && ts.sys.setTimeout) || setTimeout, + clearTimeout: (ts.sys.setTimeout && ts.sys.clearTimeout) || clearTimeout, + }; +} + +/** + * The logic in this function is adapted from `tsc.ts` from TypeScript. + */ +export function performWatchCompilation(host: PerformWatchHost): { + close: () => void, + ready: (cb: () => void) => void, + firstCompileResult: PerformCompilationResult | undefined +} { + let cachedProgram: api.Program|undefined; // Program cached from last compilation + let cachedCompilerHost: api.CompilerHost|undefined; // CompilerHost cached from last compilation + let cachedOptions: ParsedConfiguration|undefined; // CompilerOptions cached from last compilation + let timerHandleForRecompilation: any; // Handle for 0.25s wait timer to trigger recompilation + + // Watch basePath, ignoring .dotfiles + const fileWatcher = host.onFileChange(watchedFileChanged); + const ingoreFilesForWatch = new Set(); + + const firstCompileResult = doCompilation(); + + const readyPromise = new Promise(resolve => fileWatcher.ready(resolve)); + + return {close, ready: cb => readyPromise.then(cb), firstCompileResult}; + + function close() { + fileWatcher.close(); + if (timerHandleForRecompilation) { + host.clearTimeout(timerHandleForRecompilation); + timerHandleForRecompilation = undefined; + } + } + + // Invoked to perform initial compilation or re-compilation in watch mode + function doCompilation() { + if (!cachedOptions) { + cachedOptions = host.readConfiguration(); + } + if (cachedOptions.errors && cachedOptions.errors.length) { + host.reportDiagnostics(cachedOptions.errors); + return; + } + if (!cachedCompilerHost) { + // TODO(chuckj): consider avoiding re-generating factories for libraries. + // Consider modifying the AotCompilerHost to be able to remember the summary files + // generated from previous compiliations and return false from isSourceFile for + // .d.ts files for which a summary file was already generated.å + cachedCompilerHost = host.createCompilerHost(cachedOptions.options); + const originalWriteFileCallback = cachedCompilerHost.writeFile; + cachedCompilerHost.writeFile = function( + fileName: string, data: string, writeByteOrderMark: boolean, + onError?: (message: string) => void, sourceFiles?: ts.SourceFile[]) { + ingoreFilesForWatch.add(path.normalize(fileName)); + return originalWriteFileCallback(fileName, data, writeByteOrderMark, onError, sourceFiles); + }; + } + ingoreFilesForWatch.clear(); + const compileResult = performCompilation({ + rootNames: cachedOptions.rootNames, + options: cachedOptions.options, + host: cachedCompilerHost, + oldProgram: cachedProgram, + emitCallback: host.createEmitCallback(cachedOptions.options) + }); + + if (compileResult.diagnostics.length) { + host.reportDiagnostics(compileResult.diagnostics); + } + + const exitCode = exitCodeFromResult(compileResult); + if (exitCode == 0) { + cachedProgram = compileResult.program; + host.reportDiagnostics([ChangeDiagnostics.Compilation_complete_Watching_for_file_changes]); + } else { + host.reportDiagnostics([ChangeDiagnostics.Compilation_failed_Watching_for_file_changes]); + } + + return compileResult; + } + + function resetOptions() { + cachedProgram = undefined; + cachedCompilerHost = undefined; + cachedOptions = undefined; + } + + function watchedFileChanged(event: FileChangeEvent, fileName: string) { + if (cachedOptions && event === FileChangeEvent.Change && + // TODO(chuckj): validate that this is sufficient to skip files that were written. + // This assumes that the file path we write is the same file path we will receive in the + // change notification. + path.normalize(fileName) === path.normalize(cachedOptions.project)) { + // If the configuration file changes, forget everything and start the recompilation timer + resetOptions(); + } else if (event === FileChangeEvent.CreateDelete) { + // If a file was added or removed, reread the configuration + // to determine the new list of root files. + cachedOptions = undefined; + } + if (!ingoreFilesForWatch.has(path.normalize(fileName))) { + // Ignore the file if the file is one that was written by the compiler. + startTimerForRecompilation(); + } + } + + // Upon detecting a file change, wait for 250ms and then perform a recompilation. This gives batch + // operations (such as saving all modified files in an editor) a chance to complete before we kick + // off a new compilation. + function startTimerForRecompilation() { + if (timerHandleForRecompilation) { + host.clearTimeout(timerHandleForRecompilation); + } + timerHandleForRecompilation = host.setTimeout(recompile, 250); + } + + function recompile() { + timerHandleForRecompilation = undefined; + host.reportDiagnostics( + [ChangeDiagnostics.File_change_detected_Starting_incremental_compilation]); + doCompilation(); + } +} \ No newline at end of file diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index ebd51c931d..2a34ed43ac 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -9,10 +9,16 @@ import {ParseSourceSpan} from '@angular/compiler'; import * as ts from 'typescript'; +export const DEFAULT_ERROR_CODE = 100; +export const UNKNOWN_ERROR_CODE = 500; +export const SOURCE = 'angular' as 'angular'; + export interface Diagnostic { - message: string; + messageText: string; span?: ParseSourceSpan; category: ts.DiagnosticCategory; + code: number; + source: 'angular'; } export interface CompilerOptions extends ts.CompilerOptions { diff --git a/packages/compiler-cli/src/transformers/program.ts b/packages/compiler-cli/src/transformers/program.ts index 15a7e8dd91..33be37321a 100644 --- a/packages/compiler-cli/src/transformers/program.ts +++ b/packages/compiler-cli/src/transformers/program.ts @@ -15,7 +15,7 @@ import * as ts from 'typescript'; import {BaseAotCompilerHost} from '../compiler_host'; import {TypeChecker} from '../diagnostics/check_types'; -import {CompilerHost, CompilerOptions, CustomTransformers, Diagnostic, EmitFlags, Program, TsEmitArguments, TsEmitCallback} from './api'; +import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, EmitFlags, Program, SOURCE, TsEmitArguments, TsEmitCallback} from './api'; import {LowerMetadataCache, getExpressionLoweringTransformFactory} from './lower_expressions'; import {getAngularEmitterTransformFactory} from './node_emitter_transform'; @@ -61,8 +61,12 @@ class AngularCompilerProgram implements Program { if (errors) { // TODO(tbosch): once we move MetadataBundler from tsc_wrapped into compiler_cli, // directly create ng.Diagnostic instead of using ts.Diagnostic here. - this._optionsDiagnostics.push( - ...errors.map(e => ({category: e.category, message: e.messageText as string}))); + this._optionsDiagnostics.push(...errors.map(e => ({ + category: e.category, + messageText: e.messageText as string, + source: SOURCE, + code: DEFAULT_ERROR_CODE + }))); } else { rootNames.push(indexName !); this.host = host = bundleHost; @@ -219,12 +223,19 @@ class AngularCompilerProgram implements Program { if (parserErrors && parserErrors.length) { this._structuralDiagnostics = parserErrors.map(e => ({ - message: e.contextualMessage(), + messageText: e.contextualMessage(), category: ts.DiagnosticCategory.Error, - span: e.span + span: e.span, + source: SOURCE, + code: DEFAULT_ERROR_CODE })); } else { - this._structuralDiagnostics = [{message: e.message, category: ts.DiagnosticCategory.Error}]; + this._structuralDiagnostics = [{ + messageText: e.message, + category: ts.DiagnosticCategory.Error, + source: SOURCE, + code: DEFAULT_ERROR_CODE + }]; } this._analyzedModules = emptyModules; return emptyModules; @@ -252,8 +263,12 @@ class AngularCompilerProgram implements Program { return this.options.skipTemplateCodegen ? [] : result; } catch (e) { if (isSyntaxError(e)) { - this._generatedFileDiagnostics = - [{message: e.message, category: ts.DiagnosticCategory.Error}]; + this._generatedFileDiagnostics = [{ + messageText: e.message, + category: ts.DiagnosticCategory.Error, + source: SOURCE, + code: DEFAULT_ERROR_CODE + }]; return []; } throw e; @@ -417,9 +432,11 @@ function getNgOptionDiagnostics(options: CompilerOptions): Diagnostic[] { break; default: return [{ - message: + messageText: 'Angular compiler options "annotationsAs" only supports "static fields" and "decorators"', - category: ts.DiagnosticCategory.Error + category: ts.DiagnosticCategory.Error, + source: SOURCE, + code: DEFAULT_ERROR_CODE }]; } } diff --git a/packages/compiler-cli/test/diagnostics/check_types_spec.ts b/packages/compiler-cli/test/diagnostics/check_types_spec.ts index 9f0b16fcf0..21769a9916 100644 --- a/packages/compiler-cli/test/diagnostics/check_types_spec.ts +++ b/packages/compiler-cli/test/diagnostics/check_types_spec.ts @@ -39,12 +39,13 @@ describe('ng type checker', () => { if (!diagnostics || !diagnostics.length) { throw new Error('Expected a diagnostic erorr message'); } else { - const matches: (d: Diagnostic) => boolean = - typeof message === 'string' ? d => d.message == message : d => message.test(d.message); + const matches: (d: Diagnostic) => boolean = typeof message === 'string' ? + d => d.messageText == message : + d => message.test(d.messageText); const matchingDiagnostics = diagnostics.filter(matches); if (!matchingDiagnostics || !matchingDiagnostics.length) { throw new Error( - `Expected a diagnostics matching ${message}, received\n ${diagnostics.map(d => d.message).join('\n ')}`); + `Expected a diagnostics matching ${message}, received\n ${diagnostics.map(d => d.messageText).join('\n ')}`); } } } @@ -173,6 +174,6 @@ const LOWERING_QUICKSTART: MockDirectory = { function expectNoDiagnostics(diagnostics: Diagnostic[]) { if (diagnostics && diagnostics.length) { - throw new Error(diagnostics.map(d => `${d.span}: ${d.message}`).join('\n')); + throw new Error(diagnostics.map(d => `${d.span}: ${d.messageText}`).join('\n')); } } diff --git a/packages/compiler-cli/test/diagnostics/mocks.ts b/packages/compiler-cli/test/diagnostics/mocks.ts index 979d352e27..60d3bbf2db 100644 --- a/packages/compiler-cli/test/diagnostics/mocks.ts +++ b/packages/compiler-cli/test/diagnostics/mocks.ts @@ -191,7 +191,7 @@ export class DiagnosticContext { analyzeHost); analyzedModules = this._analyzedModules = - analyzeNgModules(programSymbols, analyzeHost, this.resolver); + analyzeNgModules(programSymbols, analyzeHost, this.staticSymbolResolver, this.resolver); } return analyzedModules; } diff --git a/packages/compiler-cli/test/main_spec.ts b/packages/compiler-cli/test/main_spec.ts index 46b109846e..281e21bd95 100644 --- a/packages/compiler-cli/test/main_spec.ts +++ b/packages/compiler-cli/test/main_spec.ts @@ -9,8 +9,9 @@ import {makeTempDir} from '@angular/tsc-wrapped/test/test_support'; import * as fs from 'fs'; import * as path from 'path'; +import * as ts from 'typescript'; -import {main} from '../src/main'; +import {main, watchMode} from '../src/main'; function getNgRootDir() { const moduleFilename = module.filename.replace(/\\/g, '/'); @@ -309,4 +310,156 @@ describe('compiler-cli with disableTransformerPipeline', () => { .catch(e => done.fail(e)); }); }); + + describe('watch mode', () => { + let timer: (() => void)|undefined = undefined; + let results: ((message: string) => void)|undefined = undefined; + let originalTimeout: number; + + function trigger() { + const delay = 1000; + setTimeout(() => { + const t = timer; + timer = undefined; + if (!t) { + fail('Unexpected state. Timer was not set.'); + } else { + t(); + } + }, delay); + } + + function whenResults(): Promise { + return new Promise(resolve => { + results = message => { + resolve(message); + results = undefined; + }; + }); + } + + function errorSpy(message: string): void { + if (results) results(message); + } + + beforeEach(() => { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; + const timerToken = 100; + spyOn(ts.sys, 'setTimeout').and.callFake((callback: () => void) => { + timer = callback; + return timerToken; + }); + spyOn(ts.sys, 'clearTimeout').and.callFake((token: number) => { + if (token == timerToken) { + timer = undefined; + } + }); + + write('greet.html', `

Hello {{name}}!

`); + write('greet.css', `p.greeting { color: #eee }`); + write('greet.ts', ` + import {Component, Input} from '@angular/core'; + + @Component({ + selector: 'greet', + templateUrl: 'greet.html', + styleUrls: ['greet.css'] + }) + export class Greet { + @Input() + name: string; + } + `); + + write('app.ts', ` + import {Component} from '@angular/core' + + @Component({ + selector: 'my-app', + template: \` +
+ +
+ \`, + }) + export class App { + name:string; + constructor() { + this.name = \`Angular!\` + } + }`); + + write('module.ts', ` + import {NgModule} from '@angular/core'; + import {Greet} from './greet'; + import {App} from './app'; + + @NgModule({ + declarations: [Greet, App] + }) + export class MyModule {} + `); + }); + + afterEach(() => { jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; }); + + function writeAppConfig(location: string) { + writeConfig(`{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "outDir": "${location}" + } + }`); + } + + function expectRecompile(cb: () => void) { + return (done: DoneFn) => { + writeAppConfig('dist'); + const compile = watchMode({p: basePath}, errorSpy); + + return new Promise(resolve => { + compile.ready(() => { + cb(); + + // Allow the watch callbacks to occur and trigger the timer. + trigger(); + + // Expect the file to trigger a result. + whenResults().then(message => { + expect(message).toMatch(/File change detected/); + compile.close(); + done(); + resolve(); + }); + }); + }); + }; + } + + it('should recompile when config file changes', expectRecompile(() => writeAppConfig('dist2'))); + + it('should recompile when a ts file changes', expectRecompile(() => { + write('greet.ts', ` + import {Component, Input} from '@angular/core'; + + @Component({ + selector: 'greet', + templateUrl: 'greet.html', + styleUrls: ['greet.css'], + }) + export class Greet { + @Input() + name: string; + age: number; + } + `); + })); + + it('should recomiple when the html file changes', + expectRecompile(() => { write('greet.html', '

Hello {{name}} again!

'); })); + + it('should recompile when the css file changes', + expectRecompile(() => { write('greet.css', `p.greeting { color: blue }`); })); + }); }); diff --git a/packages/compiler/src/aot/compiler.ts b/packages/compiler/src/aot/compiler.ts index 36e9ac582a..b3e6a6cb5a 100644 --- a/packages/compiler/src/aot/compiler.ts +++ b/packages/compiler/src/aot/compiler.ts @@ -42,8 +42,8 @@ export class AotCompiler { analyzeModulesSync(rootFiles: string[]): NgAnalyzedModules { const programSymbols = extractProgramSymbols(this._symbolResolver, rootFiles, this._host); - const analyzeResult = - analyzeAndValidateNgModules(programSymbols, this._host, this._metadataResolver); + const analyzeResult = analyzeAndValidateNgModules( + programSymbols, this._host, this._symbolResolver, this._metadataResolver); analyzeResult.ngModules.forEach( ngModule => this._metadataResolver.loadNgModuleDirectiveAndPipeMetadata( ngModule.type.reference, true)); @@ -52,8 +52,8 @@ export class AotCompiler { analyzeModulesAsync(rootFiles: string[]): Promise { const programSymbols = extractProgramSymbols(this._symbolResolver, rootFiles, this._host); - const analyzeResult = - analyzeAndValidateNgModules(programSymbols, this._host, this._metadataResolver); + const analyzeResult = analyzeAndValidateNgModules( + programSymbols, this._host, this._symbolResolver, this._metadataResolver); return Promise .all(analyzeResult.ngModules.map( ngModule => this._metadataResolver.loadNgModuleDirectiveAndPipeMetadata( @@ -400,16 +400,24 @@ export interface NgAnalyzeModulesHost { isSourceFile(filePath: string): boolean; // Returns all the source files and a mapping from modules to directives export function analyzeNgModules( programStaticSymbols: StaticSymbol[], host: NgAnalyzeModulesHost, + staticSymbolResolver: StaticSymbolResolver, metadataResolver: CompileMetadataResolver): NgAnalyzedModules { + const programStaticSymbolsWithDecorators = programStaticSymbols.filter( + symbol => !symbol.filePath.endsWith('.d.ts') || + staticSymbolResolver.hasDecorators(symbol.filePath)); const {ngModules, symbolsMissingModule} = - _createNgModules(programStaticSymbols, host, metadataResolver); - return _analyzeNgModules(programStaticSymbols, ngModules, symbolsMissingModule, metadataResolver); + _createNgModules(programStaticSymbolsWithDecorators, host, metadataResolver); + return _analyzeNgModules( + programStaticSymbols, programStaticSymbolsWithDecorators, ngModules, symbolsMissingModule, + metadataResolver); } export function analyzeAndValidateNgModules( programStaticSymbols: StaticSymbol[], host: NgAnalyzeModulesHost, + staticSymbolResolver: StaticSymbolResolver, metadataResolver: CompileMetadataResolver): NgAnalyzedModules { - const result = analyzeNgModules(programStaticSymbols, host, metadataResolver); + const result = + analyzeNgModules(programStaticSymbols, host, staticSymbolResolver, metadataResolver); if (result.symbolsMissingModule && result.symbolsMissingModule.length) { const messages = result.symbolsMissingModule.map( s => @@ -420,8 +428,8 @@ export function analyzeAndValidateNgModules( } function _analyzeNgModules( - programSymbols: StaticSymbol[], ngModuleMetas: CompileNgModuleMetadata[], - symbolsMissingModule: StaticSymbol[], + programSymbols: StaticSymbol[], programSymbolsWithDecorators: StaticSymbol[], + ngModuleMetas: CompileNgModuleMetadata[], symbolsMissingModule: StaticSymbol[], metadataResolver: CompileMetadataResolver): NgAnalyzedModules { const moduleMetasByRef = new Map(); ngModuleMetas.forEach((ngModule) => moduleMetasByRef.set(ngModule.type.reference, ngModule)); @@ -436,7 +444,10 @@ function _analyzeNgModules( programSymbols.forEach((symbol) => { const filePath = symbol.filePath; filePaths.add(filePath); + }); + programSymbolsWithDecorators.forEach((symbol) => { if (metadataResolver.isInjectable(symbol)) { + const filePath = symbol.filePath; ngInjectablesByFile.set(filePath, (ngInjectablesByFile.get(filePath) || []).concat(symbol)); } }); diff --git a/packages/compiler/src/aot/static_symbol_resolver.ts b/packages/compiler/src/aot/static_symbol_resolver.ts index c38ef57bd5..b3a1696b65 100644 --- a/packages/compiler/src/aot/static_symbol_resolver.ts +++ b/packages/compiler/src/aot/static_symbol_resolver.ts @@ -250,6 +250,24 @@ export class StaticSymbolResolver { return this.staticSymbolCache.get(declarationFile, name, members); } + /** + * hasDecorators checks a file's metadata for the presense of decorators without evalutating the + * metada. + * + * @param filePath the absolute path to examine for decorators. + * @returns true if any class in the file has a decorator. + */ + hasDecorators(filePath: string): boolean { + const metadata = this.getModuleMetadata(filePath); + if (metadata['metadata']) { + return Object.keys(metadata['metadata']).some((metadataKey) => { + const entry = metadata['metadata'][metadataKey]; + return entry && entry.__symbolic === 'class' && entry.decorators; + }); + } + return false; + } + getSymbolsOf(filePath: string): StaticSymbol[] { // Note: Some users use libraries that were not compiled with ngc, i.e. they don't // have summaries, only .d.ts files. So we always need to check both, the summary diff --git a/packages/compiler/src/i18n/extractor.ts b/packages/compiler/src/i18n/extractor.ts index 3b573e7f47..064347a120 100644 --- a/packages/compiler/src/i18n/extractor.ts +++ b/packages/compiler/src/i18n/extractor.ts @@ -57,8 +57,8 @@ export class Extractor { extract(rootFiles: string[]): Promise { const programSymbols = extractProgramSymbols(this.staticSymbolResolver, rootFiles, this.host); - const {files, ngModules} = - analyzeAndValidateNgModules(programSymbols, this.host, this.metadataResolver); + const {files, ngModules} = analyzeAndValidateNgModules( + programSymbols, this.host, this.staticSymbolResolver, this.metadataResolver); return Promise .all(ngModules.map( ngModule => this.metadataResolver.loadNgModuleDirectiveAndPipeMetadata( diff --git a/packages/language-service/src/typescript_host.ts b/packages/language-service/src/typescript_host.ts index ae6edd4330..930b59e12f 100644 --- a/packages/language-service/src/typescript_host.ts +++ b/packages/language-service/src/typescript_host.ts @@ -151,7 +151,7 @@ export class TypeScriptServiceHost implements LanguageServiceHost { analyzeHost); analyzedModules = this.analyzedModules = - analyzeNgModules(programSymbols, analyzeHost, this.resolver); + analyzeNgModules(programSymbols, analyzeHost, this.staticSymbolResolver, this.resolver); } return analyzedModules; }