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",
|
||||
"description": "Migrates ViewChild and ContentChild to explicit query timing",
|
||||
"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 = [
|
||||
"//packages/core/schematics/migrations/static-queries",
|
||||
"//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",
|
||||
"@npm//@angular-devkit/schematics",
|
||||
"@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,
|
||||
"noImplicitReturns": true,
|
||||
"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 & {
|
||||
expression: ts.CallExpression;
|
||||
}
|
||||
};
|
||||
|
||||
export interface NgDecorator {
|
||||
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