Skip to content

Commit

Permalink
feat(core): add undecorated classes with decorated fields schematic (a…
Browse files Browse the repository at this point in the history
…ngular#32130)

Adds a schematic that adds a `Directive` decorator to undecorated classes that have fields that use Angular decorators.

PR Close angular#32130
  • Loading branch information
crisbeto authored and atscott committed Aug 22, 2019
1 parent b6fa929 commit 904a201
Show file tree
Hide file tree
Showing 11 changed files with 720 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/core/schematics/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ npm_package(
"//packages/core/schematics/migrations/renderer-to-renderer2",
"//packages/core/schematics/migrations/static-queries",
"//packages/core/schematics/migrations/template-var-assignment",
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
"//packages/core/schematics/migrations/undecorated-classes-with-di",
],
)
5 changes: 5 additions & 0 deletions packages/core/schematics/migrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"version": "9-beta",
"description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.",
"factory": "./migrations/undecorated-classes-with-di/index"
},
"migration-v9-undecorated-classes-with-decorated-fields": {
"version": "9-beta",
"description": "Adds an Angular decorator to undecorated classes that have decorated fields",
"factory": "./migrations/undecorated-classes-with-decorated-fields/index"
}
}
}
1 change: 1 addition & 0 deletions packages/core/schematics/migrations/google3/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ts_library(
"//packages/core/schematics/migrations/renderer-to-renderer2",
"//packages/core/schematics/migrations/static-queries",
"//packages/core/schematics/migrations/template-var-assignment",
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
"//packages/core/schematics/utils",
"//packages/core/schematics/utils/tslint",
"@npm//tslint",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @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 {Replacement, RuleFailure, Rules} from 'tslint';
import * as ts from 'typescript';

import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from '../undecorated-classes-with-decorated-fields/utils';



/**
* TSLint rule that adds an Angular decorator to classes that have Angular field decorators.
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
*/
export class Rule extends Rules.TypedRule {
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
const typeChecker = program.getTypeChecker();
const printer = ts.createPrinter();
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);

return classes.map((current, index) => {
const {classDeclaration: declaration, importDeclaration} = current;
const name = declaration.name;

// Set the class identifier node (if available) as the failing node so IDEs don't highlight
// the entire class with red. This is similar to how errors are shown for classes in other
// cases like an interface not being implemented correctly.
const start = (name || declaration).getStart();
const end = (name || declaration).getEnd();
const fixes = [Replacement.appendText(declaration.getStart(), `@${FALLBACK_DECORATOR}()\n`)];

// If it's the first class that we're processing in this file, add `Directive` to the imports.
if (index === 0 && !hasNamedImport(importDeclaration, FALLBACK_DECORATOR)) {
const namedImports = getNamedImports(importDeclaration);

if (namedImports) {
fixes.push(new Replacement(
namedImports.getStart(), namedImports.getWidth(),
printer.printNode(
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
sourceFile)));
}
}

return new RuleFailure(
sourceFile, start, end,
'Classes with decorated fields must have an Angular decorator as well.',
'undecorated-classes-with-decorated-fields', fixes);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
load("//tools:defaults.bzl", "ts_library")

ts_library(
name = "undecorated-classes-with-decorated-fields",
srcs = glob(["**/*.ts"]),
tsconfig = "//packages/core/schematics:tsconfig.json",
visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/migrations/google3:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
deps = [
"//packages/core/schematics/utils",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
"@npm//typescript",
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Undecorated classes with decorated fields migration

Automatically adds a `Directive` decorator to undecorated classes that have fields with Angular
decorators. Also adds the relevant imports, if necessary.

#### Before
```ts
import { Input } from '@angular/core';

export class Base {
@Input() isActive: boolean;
}
```

#### After
```ts
import { Input, Directive } from '@angular/core';

@Directive()
export class Base {
@Input() isActive: boolean;
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @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 {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 {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from './utils';


/**
* Migration that adds an Angular decorator to classes that have Angular field decorators.
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
*/
export default function(): Rule {
return (tree: Tree, context: SchematicContext) => {
const {buildPaths, testPaths} = getProjectTsConfigPaths(tree);
const basePath = process.cwd();
const allPaths = [...buildPaths, ...testPaths];
const logger = context.logger;

logger.info('------ Undecorated classes with decorated fields migration ------');
logger.info(
'As of Angular 9, it is no longer supported to have Angular field ' +
'decorators on a class that does not have an Angular decorator.');

if (!allPaths.length) {
throw new SchematicsException(
'Could not find any tsconfig file. Cannot add an Angular decorator to undecorated classes.');
}

for (const tsconfigPath of allPaths) {
runUndecoratedClassesMigration(tree, tsconfigPath, basePath);
}
};
}

function runUndecoratedClassesMigration(tree: Tree, tsconfigPath: string, basePath: string) {
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. Otherwise
// if we run the migration for multiple tsconfig files which have intersecting
// source files, it can end up updating them multiple times.
host.readFile = fileName => {
const buffer = tree.read(relative(basePath, fileName));
// Strip BOM as otherwise TSC methods (Ex: getWidth) will return an offset which
// which breaks the CLI UpdateRecorder.
// See: https://github.com/angular/angular/pull/30719
return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined;
};

const program = ts.createProgram(parsed.fileNames, parsed.options, host);
const typeChecker = program.getTypeChecker();
const printer = ts.createPrinter();
const sourceFiles = program.getSourceFiles().filter(
file => !file.isDeclarationFile && !program.isSourceFileFromExternalLibrary(file));

sourceFiles.forEach(sourceFile => {
const update = tree.beginUpdate(relative(basePath, sourceFile.fileName));
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);

classes.forEach((current, index) => {
// If it's the first class that we're processing in this file, add `Directive` to the imports.
if (index === 0 && !hasNamedImport(current.importDeclaration, FALLBACK_DECORATOR)) {
const namedImports = getNamedImports(current.importDeclaration);

if (namedImports) {
update.remove(namedImports.getStart(), namedImports.getWidth());
update.insertRight(
namedImports.getStart(),
printer.printNode(
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
sourceFile));
}
}

// We don't need to go through the AST to insert the decorator, because the change
// is pretty basic. Also this has a better chance of preserving the user's formatting.
update.insertLeft(current.classDeclaration.getStart(), `@${FALLBACK_DECORATOR}()\n`);
});

tree.commitUpdate(update);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @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';
import {getAngularDecorators} from '../../utils/ng_decorators';

/** Name of the decorator that should be added to undecorated classes. */
export const FALLBACK_DECORATOR = 'Directive';

/** Finds all of the undecorated classes that have decorated fields within a file. */
export function getUndecoratedClassesWithDecoratedFields(
sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) {
const classes: UndecoratedClassWithDecoratedFields[] = [];

sourceFile.forEachChild(function walk(node: ts.Node) {
if (ts.isClassDeclaration(node) &&
(!node.decorators || !getAngularDecorators(typeChecker, node.decorators).length)) {
for (const member of node.members) {
const angularDecorators =
member.decorators && getAngularDecorators(typeChecker, member.decorators);

if (angularDecorators && angularDecorators.length) {
classes.push(
{classDeclaration: node, importDeclaration: angularDecorators[0].importNode});
return;
}
}
}

node.forEachChild(walk);
});

return classes;
}

/** Checks whether an import declaration has an import with a certain name. */
export function hasNamedImport(declaration: ts.ImportDeclaration, symbolName: string): boolean {
const namedImports = getNamedImports(declaration);

if (namedImports) {
return namedImports.elements.some(element => {
const {name, propertyName} = element;
return propertyName ? propertyName.text === symbolName : name.text === symbolName;
});
}

return false;
}

/** Extracts the NamedImports node from an import declaration. */
export function getNamedImports(declaration: ts.ImportDeclaration): ts.NamedImports|null {
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
return (namedBindings && ts.isNamedImports(namedBindings)) ? namedBindings : null;
}

/** Adds a new import to a NamedImports node. */
export function addImport(declaration: ts.NamedImports, symbolName: string) {
return ts.updateNamedImports(declaration, [
...declaration.elements, ts.createImportSpecifier(undefined, ts.createIdentifier(symbolName))
]);
}

interface UndecoratedClassWithDecoratedFields {
classDeclaration: ts.ClassDeclaration;
importDeclaration: ts.ImportDeclaration;
}
1 change: 1 addition & 0 deletions packages/core/schematics/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ ts_library(
"//packages/core/schematics/migrations/renderer-to-renderer2",
"//packages/core/schematics/migrations/static-queries",
"//packages/core/schematics/migrations/template-var-assignment",
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
"//packages/core/schematics/migrations/undecorated-classes-with-di",
"//packages/core/schematics/utils",
"@npm//@angular-devkit/core",
Expand Down
Loading

0 comments on commit 904a201

Please sign in to comment.