feat(core): template-var-assignment update schematic (#29608)
Introduces a new update schematic called "template-var-assignment" that is responsible for analyzing template files in order to warn developers if template variables are assigned to values. The schematic also comes with a driver for `tslint` so that the check can be used wtihin Google. PR Close #29608
This commit is contained in:
parent
15eb1e0ce1
commit
7c8f4e3202
|
@ -4,6 +4,11 @@
|
||||||
"version": "8",
|
"version": "8",
|
||||||
"description": "Migrates ViewChild and ContentChild to explicit query timing",
|
"description": "Migrates ViewChild and ContentChild to explicit query timing",
|
||||||
"factory": "./migrations/static-queries/index"
|
"factory": "./migrations/static-queries/index"
|
||||||
|
},
|
||||||
|
"migration-v8-template-local-variables": {
|
||||||
|
"version": "8",
|
||||||
|
"description": "Warns developers if values are assigned to template variables",
|
||||||
|
"factory": "./migrations/template-var-assignment/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "template-var-assignment",
|
||||||
|
srcs = glob(["**/*.ts"]),
|
||||||
|
tsconfig = "//packages/core/schematics:tsconfig.json",
|
||||||
|
visibility = [
|
||||||
|
"//packages/core/schematics:__pkg__",
|
||||||
|
"//packages/core/schematics/migrations/template-var-assignment/google3:__pkg__",
|
||||||
|
"//packages/core/schematics/test:__pkg__",
|
||||||
|
],
|
||||||
|
deps = [
|
||||||
|
"//packages/compiler",
|
||||||
|
"//packages/core/schematics/utils",
|
||||||
|
"@npm//@angular-devkit/schematics",
|
||||||
|
"@npm//@types/node",
|
||||||
|
"@npm//typescript",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* @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 {PropertyWrite, parseTemplate} from '@angular/compiler';
|
||||||
|
import {Variable, visitAll} from '@angular/compiler/src/render3/r3_ast';
|
||||||
|
|
||||||
|
import {ResolvedTemplate} from './angular/ng_component_template';
|
||||||
|
import {PropertyAssignment, PropertyWriteHtmlVisitor} from './angular/property_write_html_visitor';
|
||||||
|
|
||||||
|
export interface TemplateVariableAssignment {
|
||||||
|
node: PropertyWrite;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes a given resolved template by looking for property assignments to local
|
||||||
|
* template variables within bound events.
|
||||||
|
*/
|
||||||
|
export function analyzeResolvedTemplate(
|
||||||
|
filePath: string, template: ResolvedTemplate): TemplateVariableAssignment[]|null {
|
||||||
|
try {
|
||||||
|
const templateNodes = parseTemplate(template.content, filePath).nodes;
|
||||||
|
const visitor = new PropertyWriteHtmlVisitor();
|
||||||
|
|
||||||
|
// Analyze the Angular Render3 HTML AST and collect all property assignments and
|
||||||
|
// template variables.
|
||||||
|
visitAll(visitor, templateNodes);
|
||||||
|
|
||||||
|
return filterTemplateVariableAssignments(visitor.propertyAssignments, visitor.templateVariables)
|
||||||
|
.map(({node, start, end}) => ({node, start: start + node.span.start, end}));
|
||||||
|
} catch {
|
||||||
|
// Do nothing if the template couldn't be parsed. We don't want to throw any
|
||||||
|
// exception if a template is syntactically not valid. e.g. template could be
|
||||||
|
// using preprocessor syntax.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all template variable assignments by looking if a given property
|
||||||
|
* assignment is setting the value for one of the specified template variables.
|
||||||
|
*/
|
||||||
|
function filterTemplateVariableAssignments(writes: PropertyAssignment[], variables: Variable[]) {
|
||||||
|
return writes.filter(propertyWrite => !!variables.find(v => v.name === propertyWrite.node.name));
|
||||||
|
}
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* @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 {existsSync, readFileSync} from 'fs';
|
||||||
|
import {dirname, resolve} from 'path';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {computeLineStartsMap, getLineAndCharacterFromPosition} from '../../../utils/line_mappings';
|
||||||
|
import {getAngularDecorators} from '../../../utils/ng_decorators';
|
||||||
|
import {unwrapExpression} from '../../../utils/typescript/functions';
|
||||||
|
import {getPropertyNameText} from '../../../utils/typescript/property_name';
|
||||||
|
|
||||||
|
export interface ResolvedTemplate {
|
||||||
|
/** File content of the given template. */
|
||||||
|
content: string;
|
||||||
|
/** Start offset of the template content (e.g. in the inline source file) */
|
||||||
|
start: number;
|
||||||
|
/** Whether the given template is inline or not. */
|
||||||
|
inline: boolean;
|
||||||
|
/**
|
||||||
|
* Gets the character and line of a given position index in the template.
|
||||||
|
* If the template is declared inline within a TypeScript source file, the line and
|
||||||
|
* character are based on the full source file content.
|
||||||
|
*/
|
||||||
|
getCharacterAndLineOfPosition: (pos: number) => { character: number, line: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visitor that can be used to determine Angular templates referenced within given
|
||||||
|
* TypeScript source files (inline templates or external referenced templates)
|
||||||
|
*/
|
||||||
|
export class NgComponentTemplateVisitor {
|
||||||
|
resolvedTemplates = new Map<string, ResolvedTemplate>();
|
||||||
|
|
||||||
|
constructor(public typeChecker: ts.TypeChecker) {}
|
||||||
|
|
||||||
|
visitNode(node: ts.Node) {
|
||||||
|
switch (node.kind) {
|
||||||
|
case ts.SyntaxKind.ClassDeclaration:
|
||||||
|
this.visitClassDeclaration(node as ts.ClassDeclaration);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, node => this.visitNode(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
private visitClassDeclaration(node: ts.ClassDeclaration) {
|
||||||
|
if (!node.decorators || !node.decorators.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators);
|
||||||
|
const componentDecorator = ngDecorators.find(dec => dec.name === 'Component');
|
||||||
|
|
||||||
|
// In case no "@Component" decorator could be found on the current class, skip.
|
||||||
|
if (!componentDecorator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoratorCall = componentDecorator.node.expression;
|
||||||
|
|
||||||
|
// In case the component decorator call is not valid, skip this class declaration.
|
||||||
|
if (decoratorCall.arguments.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentMetadata = unwrapExpression(decoratorCall.arguments[0]);
|
||||||
|
|
||||||
|
// Ensure that the component metadata is an object literal expression.
|
||||||
|
if (!ts.isObjectLiteralExpression(componentMetadata)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFile = node.getSourceFile();
|
||||||
|
const sourceFileName = sourceFile.fileName;
|
||||||
|
|
||||||
|
// Walk through all component metadata properties and determine the referenced
|
||||||
|
// HTML templates (either external or inline)
|
||||||
|
componentMetadata.properties.forEach(property => {
|
||||||
|
if (!ts.isPropertyAssignment(property)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyName = getPropertyNameText(property.name);
|
||||||
|
|
||||||
|
// In case there is an inline template specified, ensure that the value is statically
|
||||||
|
// analyzable by checking if the initializer is a string literal-like node.
|
||||||
|
if (propertyName === 'template' && ts.isStringLiteralLike(property.initializer)) {
|
||||||
|
// Need to add an offset of one to the start because the template quotes are
|
||||||
|
// not part of the template content.
|
||||||
|
const templateStartIdx = property.initializer.getStart() + 1;
|
||||||
|
this.resolvedTemplates.set(resolve(sourceFileName), {
|
||||||
|
content: property.initializer.text,
|
||||||
|
inline: true,
|
||||||
|
start: templateStartIdx,
|
||||||
|
getCharacterAndLineOfPosition:
|
||||||
|
pos => ts.getLineAndCharacterOfPosition(sourceFile, pos + templateStartIdx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (propertyName === 'templateUrl' && ts.isStringLiteralLike(property.initializer)) {
|
||||||
|
const templatePath = resolve(dirname(sourceFileName), property.initializer.text);
|
||||||
|
|
||||||
|
// In case the template does not exist in the file system, skip this
|
||||||
|
// external template.
|
||||||
|
if (!existsSync(templatePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileContent = readFileSync(templatePath, 'utf8');
|
||||||
|
const lineStartsMap = computeLineStartsMap(fileContent);
|
||||||
|
|
||||||
|
this.resolvedTemplates.set(templatePath, {
|
||||||
|
content: fileContent,
|
||||||
|
inline: false,
|
||||||
|
start: 0,
|
||||||
|
getCharacterAndLineOfPosition: pos => getLineAndCharacterFromPosition(lineStartsMap, pos),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* @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 {ParseSourceSpan, PropertyWrite, RecursiveAstVisitor} from '@angular/compiler';
|
||||||
|
import {BoundEvent, Element, NullVisitor, Template, Variable, visitAll} from '@angular/compiler/src/render3/r3_ast';
|
||||||
|
|
||||||
|
export interface PropertyAssignment {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
node: PropertyWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AST visitor that traverses the Render3 HTML AST in order to find all declared
|
||||||
|
* template variables and property assignments within bound events.
|
||||||
|
*/
|
||||||
|
export class PropertyWriteHtmlVisitor extends NullVisitor {
|
||||||
|
templateVariables: Variable[] = [];
|
||||||
|
propertyAssignments: PropertyAssignment[] = [];
|
||||||
|
|
||||||
|
private expressionAstVisitor = new ExpressionAstVisitor(this.propertyAssignments);
|
||||||
|
|
||||||
|
visitElement(element: Element): void {
|
||||||
|
visitAll(this, element.outputs);
|
||||||
|
visitAll(this, element.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
visitTemplate(template: Template): void {
|
||||||
|
// Visit all children of the template. The template proxies the outputs of the
|
||||||
|
// immediate child elements, so we just ignore outputs on the "Template" in order
|
||||||
|
// to not visit similar bound events twice.
|
||||||
|
visitAll(this, template.children);
|
||||||
|
|
||||||
|
// Keep track of all declared local template variables.
|
||||||
|
this.templateVariables.push(...template.variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
visitBoundEvent(node: BoundEvent) {
|
||||||
|
node.handler.visit(this.expressionAstVisitor, node.handlerSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AST visitor that resolves all property assignments with a given expression AST. */
|
||||||
|
class ExpressionAstVisitor extends RecursiveAstVisitor {
|
||||||
|
constructor(private propertyAssignments: PropertyAssignment[]) { super(); }
|
||||||
|
|
||||||
|
visitPropertyWrite(node: PropertyWrite, span: ParseSourceSpan) {
|
||||||
|
this.propertyAssignments.push({
|
||||||
|
node: node,
|
||||||
|
start: span.start.offset,
|
||||||
|
end: span.end.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
super.visitPropertyWrite(node, span);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "google3",
|
||||||
|
srcs = glob(["**/*.ts"]),
|
||||||
|
tsconfig = "//packages/core/schematics:tsconfig.json",
|
||||||
|
visibility = ["//packages/core/schematics/test:__pkg__"],
|
||||||
|
deps = [
|
||||||
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
|
"//packages/core/schematics/utils/tslint",
|
||||||
|
"@npm//tslint",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* @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 {RuleFailure, Rules} from 'tslint';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {createHtmlSourceFile} from '../../../utils/tslint/tslint_html_source_file';
|
||||||
|
import {analyzeResolvedTemplate} from '../analyze_template';
|
||||||
|
import {NgComponentTemplateVisitor} from '../angular/ng_component_template';
|
||||||
|
|
||||||
|
const FAILURE_MESSAGE = 'Found assignment to template variable. This does not work with Ivy and ' +
|
||||||
|
'needs to be updated.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule that reports if an Angular template contains property assignments to template variables.
|
||||||
|
*/
|
||||||
|
export class Rule extends Rules.TypedRule {
|
||||||
|
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
|
||||||
|
const typeChecker = program.getTypeChecker();
|
||||||
|
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
|
||||||
|
const failures: RuleFailure[] = [];
|
||||||
|
|
||||||
|
// Analyze the current source files by detecting all referenced HTML templates.
|
||||||
|
templateVisitor.visitNode(sourceFile);
|
||||||
|
|
||||||
|
const {resolvedTemplates} = templateVisitor;
|
||||||
|
|
||||||
|
// Analyze each resolved template and print a warning for property writes to
|
||||||
|
// template variables.
|
||||||
|
resolvedTemplates.forEach((template, filePath) => {
|
||||||
|
const nodes = analyzeResolvedTemplate(filePath, template);
|
||||||
|
const templateFile =
|
||||||
|
template.inline ? sourceFile : createHtmlSourceFile(filePath, template.content);
|
||||||
|
|
||||||
|
if (!nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach(n => {
|
||||||
|
failures.push(new RuleFailure(
|
||||||
|
templateFile, template.start + n.start, template.start + n.end, FAILURE_MESSAGE,
|
||||||
|
this.ruleName));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @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 {logging, normalize} from '@angular-devkit/core';
|
||||||
|
import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics';
|
||||||
|
import {dirname, relative} from 'path';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
|
||||||
|
import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig';
|
||||||
|
|
||||||
|
import {analyzeResolvedTemplate} from './analyze_template';
|
||||||
|
import {NgComponentTemplateVisitor} from './angular/ng_component_template';
|
||||||
|
|
||||||
|
type Logger = logging.LoggerApi;
|
||||||
|
|
||||||
|
/** Entry point for the V8 template variable assignment schematic. */
|
||||||
|
export default function(): Rule {
|
||||||
|
return (tree: Tree, context: SchematicContext) => {
|
||||||
|
const projectTsConfigPaths = getProjectTsConfigPaths(tree);
|
||||||
|
const basePath = process.cwd();
|
||||||
|
|
||||||
|
if (!projectTsConfigPaths.length) {
|
||||||
|
throw new SchematicsException(
|
||||||
|
'Could not find any tsconfig file. Cannot check templates for template variable ' +
|
||||||
|
'assignments.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tsconfigPath of projectTsConfigPaths) {
|
||||||
|
runTemplateVariableAssignmentCheck(tree, tsconfigPath, basePath, context.logger);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the template variable assignment check. Warns developers
|
||||||
|
* if values are assigned to template variables within output bindings.
|
||||||
|
*/
|
||||||
|
function runTemplateVariableAssignmentCheck(
|
||||||
|
tree: Tree, tsconfigPath: string, basePath: string, logger: Logger) {
|
||||||
|
const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath));
|
||||||
|
const host = ts.createCompilerHost(parsed.options, true);
|
||||||
|
|
||||||
|
// We need to overwrite the host "readFile" method, as we want the TypeScript
|
||||||
|
// program to be based on the file contents in the virtual file tree.
|
||||||
|
host.readFile = fileName => {
|
||||||
|
const buffer = tree.read(relative(basePath, fileName));
|
||||||
|
return buffer ? buffer.toString() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
|
||||||
|
const typeChecker = program.getTypeChecker();
|
||||||
|
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
|
||||||
|
const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !);
|
||||||
|
|
||||||
|
// Analyze source files by detecting HTML templates.
|
||||||
|
rootSourceFiles.forEach(sourceFile => templateVisitor.visitNode(sourceFile));
|
||||||
|
|
||||||
|
const {resolvedTemplates} = templateVisitor;
|
||||||
|
|
||||||
|
// Analyze each resolved template and print a warning for property writes to
|
||||||
|
// template variables.
|
||||||
|
resolvedTemplates.forEach((template, filePath) => {
|
||||||
|
const nodes = analyzeResolvedTemplate(filePath, template);
|
||||||
|
|
||||||
|
if (!nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayFilePath = normalize(relative(basePath, filePath));
|
||||||
|
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const {line, character} = template.getCharacterAndLineOfPosition(n.start);
|
||||||
|
logger.warn(
|
||||||
|
`${displayFilePath}@${line + 1}:${character + 1}: Found assignment to template ` +
|
||||||
|
`variable. This does not work with Ivy and needs to be updated.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -11,6 +11,8 @@ ts_library(
|
||||||
deps = [
|
deps = [
|
||||||
"//packages/core/schematics/migrations/static-queries",
|
"//packages/core/schematics/migrations/static-queries",
|
||||||
"//packages/core/schematics/migrations/static-queries/google3",
|
"//packages/core/schematics/migrations/static-queries/google3",
|
||||||
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
|
"//packages/core/schematics/migrations/template-var-assignment/google3",
|
||||||
"//packages/core/schematics/utils",
|
"//packages/core/schematics/utils",
|
||||||
"@npm//@angular-devkit/schematics",
|
"@npm//@angular-devkit/schematics",
|
||||||
"@npm//@types/shelljs",
|
"@npm//@types/shelljs",
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* @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 {writeFileSync} from 'fs';
|
||||||
|
import {dirname, join} from 'path';
|
||||||
|
import * as shx from 'shelljs';
|
||||||
|
import {Configuration, Linter} from 'tslint';
|
||||||
|
|
||||||
|
describe('Google3 noTemplateVariableAssignment TSLint rule', () => {
|
||||||
|
const rulesDirectory = dirname(require.resolve(
|
||||||
|
'../../migrations/template-var-assignment/google3/noTemplateVariableAssignmentRule'));
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = join(process.env['TEST_TMPDIR'] !, 'google3-test');
|
||||||
|
shx.mkdir('-p', tmpDir);
|
||||||
|
|
||||||
|
writeFile('tsconfig.json', JSON.stringify({compilerOptions: {module: 'es2015'}}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => shx.rm('-r', tmpDir));
|
||||||
|
|
||||||
|
/** Runs TSLint with the no-template-variable TSLint rule.*/
|
||||||
|
function runTSLint() {
|
||||||
|
const program = Linter.createProgram(join(tmpDir, 'tsconfig.json'));
|
||||||
|
const linter = new Linter({fix: false, rulesDirectory: [rulesDirectory]}, program);
|
||||||
|
const config = Configuration.parseConfigFile(
|
||||||
|
{rules: {'no-template-variable-assignment': true}, linterOptions: {typeCheck: true}});
|
||||||
|
|
||||||
|
program.getRootFileNames().forEach(fileName => {
|
||||||
|
linter.lint(fileName, program.getSourceFile(fileName) !.getFullText(), config);
|
||||||
|
});
|
||||||
|
|
||||||
|
return linter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a file to the current temporary directory. */
|
||||||
|
function writeFile(fileName: string, content: string) {
|
||||||
|
writeFileSync(join(tmpDir, fileName), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should create failure for detected two-way data binding assignment', () => {
|
||||||
|
writeFile('index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({template: '<span *ngFor="let i of options" [(a)]="i"></span>'})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const linter = runTSLint();
|
||||||
|
const failures = linter.getResult().failures;
|
||||||
|
|
||||||
|
expect(failures.length).toBe(1);
|
||||||
|
expect(failures[0].getFileName()).toContain('index.ts');
|
||||||
|
expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 3, character: 68});
|
||||||
|
expect(failures[0].getEndPosition().getLineAndCharacter()).toEqual({line: 3, character: 69});
|
||||||
|
expect(failures[0].getFailure()).toMatch(/^Found assignment to template variable./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create failure with correct offsets for external templates', () => {
|
||||||
|
writeFile('index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({templateUrl: './my-tmpl.html'})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile(`my-tmpl.html`, `
|
||||||
|
<span *ngFor="let option of options" [(a)]="option"></span>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const linter = runTSLint();
|
||||||
|
const failures = linter.getResult().failures;
|
||||||
|
|
||||||
|
expect(failures.length).toBe(1);
|
||||||
|
expect(failures[0].getFileName()).toContain('my-tmpl.html');
|
||||||
|
expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 1, character: 50});
|
||||||
|
expect(failures[0].getEndPosition().getLineAndCharacter()).toEqual({line: 1, character: 56});
|
||||||
|
expect(failures[0].getFailure()).toMatch(/^Found assignment to template variable./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create failure for template variable assignment within output', () => {
|
||||||
|
writeFile('index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({templateUrl: './my-tmpl.html'})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile(`my-tmpl.html`, `
|
||||||
|
<!-- Comment -->
|
||||||
|
<span *ngFor="let option of options" (click)="option = true"></span>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const linter = runTSLint();
|
||||||
|
const failures = linter.getResult().failures;
|
||||||
|
|
||||||
|
expect(failures.length).toBe(1);
|
||||||
|
expect(failures[0].getFileName()).toContain('my-tmpl.html');
|
||||||
|
expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 2, character: 52});
|
||||||
|
expect(failures[0].getEndPosition().getLineAndCharacter()).toEqual({line: 2, character: 65});
|
||||||
|
expect(failures[0].getFailure()).toMatch(/^Found assignment to template variable./);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* @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 {computeLineStartsMap, getLineAndCharacterFromPosition} from '../utils/line_mappings';
|
||||||
|
|
||||||
|
describe('line mappings', () => {
|
||||||
|
|
||||||
|
it('should properly compute line starts',
|
||||||
|
() => {
|
||||||
|
expect(computeLineStartsMap(`
|
||||||
|
1
|
||||||
|
2`)).toEqual([0, 1, 9, 16]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly get line and character from line starts', () => {
|
||||||
|
const lineStarts = computeLineStartsMap(`
|
||||||
|
1
|
||||||
|
2`);
|
||||||
|
|
||||||
|
expect(getLineAndCharacterFromPosition(lineStarts, 8)).toEqual({
|
||||||
|
line: 1,
|
||||||
|
character: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core';
|
||||||
|
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
|
||||||
|
import {HostTree} from '@angular-devkit/schematics';
|
||||||
|
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
|
||||||
|
import * as shx from 'shelljs';
|
||||||
|
|
||||||
|
describe('template variable assignment migration', () => {
|
||||||
|
let runner: SchematicTestRunner;
|
||||||
|
let host: TempScopedNodeJsSyncHost;
|
||||||
|
let tree: UnitTestTree;
|
||||||
|
let tmpDirPath: string;
|
||||||
|
let previousWorkingDir: string;
|
||||||
|
let consoleOutput: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runner = new SchematicTestRunner('test', require.resolve('../migrations.json'));
|
||||||
|
host = new TempScopedNodeJsSyncHost();
|
||||||
|
tree = new UnitTestTree(new HostTree(host));
|
||||||
|
|
||||||
|
writeFile('/tsconfig.json', JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
lib: ['es2015'],
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
consoleOutput = [];
|
||||||
|
runner.logger.subscribe(logEntry => consoleOutput.push(logEntry.message));
|
||||||
|
|
||||||
|
previousWorkingDir = shx.pwd();
|
||||||
|
tmpDirPath = getSystemPath(host.root);
|
||||||
|
|
||||||
|
// Switch into the temporary directory path. This allows us to run
|
||||||
|
// the schematic against our custom unit test tree.
|
||||||
|
shx.cd(tmpDirPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shx.cd(previousWorkingDir);
|
||||||
|
shx.rm('-r', tmpDirPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeFile(filePath: string, contents: string) {
|
||||||
|
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigration() {
|
||||||
|
runner.runSchematic('migration-v8-template-local-variables', {}, tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should warn for two-way data binding variable assignment', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '<cmp *ngFor="let optionName of options" [(opt)]="optionName"></cmp>',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(1);
|
||||||
|
expect(consoleOutput[0]).toMatch(/^index.ts@5:69: Found assignment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn for two-way data binding assigning to "as" variable', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './tmpl.html',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile('/tmpl.html', `
|
||||||
|
<div *ngIf="somePartner() | async as partner">
|
||||||
|
<some-comp [(value)]="partner"></some-comp>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(1);
|
||||||
|
expect(consoleOutput).toMatch(/^tmpl.html@3:31: Found assignment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn for bound event assignments to "as" variable', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './sub_dir/tmpl.html',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile('/sub_dir/tmpl.html', `
|
||||||
|
<div *ngIf="true as visible">
|
||||||
|
<div (click)="visible=false">Hide</div>
|
||||||
|
<div (click)="visible=true">Show</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(2);
|
||||||
|
expect(consoleOutput[0]).toMatch(/^sub_dir\/tmpl.html@3:25: Found assignment/);
|
||||||
|
expect(consoleOutput[1]).toMatch(/^sub_dir\/tmpl.html@4:25: Found assignment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn for bound event assignments to template "let" variables', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './sub_dir/tmpl.html',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile('/sub_dir/tmpl.html', `
|
||||||
|
<ng-template let-visible="false">
|
||||||
|
<div (click)="visible=false">Hide</div>
|
||||||
|
<div (click)="visible=true">Show</div>
|
||||||
|
</ng-template>
|
||||||
|
`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(2);
|
||||||
|
expect(consoleOutput[0]).toMatch(/^sub_dir\/tmpl.html@3:25: Found assignment/);
|
||||||
|
expect(consoleOutput[1]).toMatch(/^sub_dir\/tmpl.html@4:25: Found assignment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn for bound event assignments to component property', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './sub_dir/tmpl.html',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile('/sub_dir/tmpl.html', `<button (click)="myProp = true"></button>`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw an error if a detected template fails parsing', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './sub_dir/tmpl.html',
|
||||||
|
})
|
||||||
|
export class MyComp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFile('/sub_dir/tmpl.html', `<x (click)="<invalid-syntax>"></x>`);
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
||||||
|
expect(consoleOutput.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,6 +3,14 @@
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"lib": ["es2015"],
|
"lib": ["es2015"],
|
||||||
"types": []
|
"types": [],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@angular/compiler": ["../../compiler"],
|
||||||
|
"@angular/compiler/*": ["../../compiler/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bazelOptions": {
|
||||||
|
"suppressTsconfigOverrideWarnings": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LF_CHAR = 10;
|
||||||
|
const CR_CHAR = 13;
|
||||||
|
const LINE_SEP_CHAR = 8232;
|
||||||
|
const PARAGRAPH_CHAR = 8233;
|
||||||
|
|
||||||
|
/** Gets the line and character for the given position from the line starts map. */
|
||||||
|
export function getLineAndCharacterFromPosition(lineStartsMap: number[], position: number) {
|
||||||
|
const lineIndex = findClosestLineStartPosition(lineStartsMap, position);
|
||||||
|
return {character: position - lineStartsMap[lineIndex], line: lineIndex};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the line start map of the given text. This can be used in order to
|
||||||
|
* retrieve the line and character of a given text position index.
|
||||||
|
*/
|
||||||
|
export function computeLineStartsMap(text: string): number[] {
|
||||||
|
const result: number[] = [0];
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < text.length) {
|
||||||
|
const char = text.charCodeAt(pos++);
|
||||||
|
// Handles the "CRLF" line break. In that case we peek the character
|
||||||
|
// after the "CR" and check if it is a line feed.
|
||||||
|
if (char === CR_CHAR) {
|
||||||
|
if (text.charCodeAt(pos) === LF_CHAR) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
result.push(pos);
|
||||||
|
} else if (char === LF_CHAR || char === LINE_SEP_CHAR || char === PARAGRAPH_CHAR) {
|
||||||
|
result.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(pos);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the closest line start for the given position. */
|
||||||
|
function findClosestLineStartPosition<T>(
|
||||||
|
linesMap: T[], position: T, low = 0, high = linesMap.length - 1) {
|
||||||
|
while (low <= high) {
|
||||||
|
const pivotIdx = Math.floor((low + high) / 2);
|
||||||
|
const pivotEl = linesMap[pivotIdx];
|
||||||
|
|
||||||
|
if (pivotEl === position) {
|
||||||
|
return pivotIdx;
|
||||||
|
} else if (position > pivotEl) {
|
||||||
|
low = pivotIdx + 1;
|
||||||
|
} else {
|
||||||
|
high = pivotIdx - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case there was no exact match, return the closest "lower" line index. We also
|
||||||
|
// subtract the index by one because want the index of the previous line start.
|
||||||
|
return low - 1;
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import {getCallDecoratorImport} from './typescript/decorators';
|
||||||
|
|
||||||
export type CallExpressionDecorator = ts.Decorator & {
|
export type CallExpressionDecorator = ts.Decorator & {
|
||||||
expression: ts.CallExpression;
|
expression: ts.CallExpression;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface NgDecorator {
|
export interface NgDecorator {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "tslint",
|
||||||
|
srcs = glob(["**/*.ts"]),
|
||||||
|
tsconfig = "//packages/core/schematics:tsconfig.json",
|
||||||
|
visibility = ["//packages/core/schematics/migrations/template-var-assignment/google3:__pkg__"],
|
||||||
|
)
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* @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 ts from 'typescript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fake TypeScript source file that can contain content of templates or stylesheets.
|
||||||
|
* The fake TypeScript source file then can be passed to TSLint in combination with a rule failure.
|
||||||
|
*/
|
||||||
|
export function createHtmlSourceFile(filePath: string, content: string): ts.SourceFile {
|
||||||
|
const sourceFile = ts.createSourceFile(filePath, `\`${content}\``, ts.ScriptTarget.ES5);
|
||||||
|
|
||||||
|
// Subtract two characters because the string literal quotes are only needed for parsing
|
||||||
|
// and are not part of the actual source file.
|
||||||
|
sourceFile.end = sourceFile.end - 2;
|
||||||
|
|
||||||
|
// Note: This does not affect the way TSLint applies replacements for external resource files.
|
||||||
|
// At the time of writing, TSLint loads files manually if the actual rule source file is not
|
||||||
|
// equal to the source file of the replacement. This means that the replacements need proper
|
||||||
|
// offsets without the string literal quote symbols.
|
||||||
|
sourceFile.getFullText = function() {
|
||||||
|
return sourceFile.text.substring(1, sourceFile.text.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceFile.getText = sourceFile.getFullText;
|
||||||
|
|
||||||
|
// Update the "text" property to be set to the template content without quotes. This is
|
||||||
|
// necessary so that the TypeScript line starts map is properly computed.
|
||||||
|
sourceFile.text = sourceFile.getFullText();
|
||||||
|
|
||||||
|
return sourceFile;
|
||||||
|
}
|
Loading…
Reference in New Issue