Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add undecorated classes with decorated fields schematic #32130

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/schematics/BUILD.bazel
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
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
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
@@ -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';
crisbeto marked this conversation as resolved.
Show resolved Hide resolved
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);
});
}
}
@@ -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",
],
)
@@ -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;
}
```
@@ -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));
crisbeto marked this conversation as resolved.
Show resolved Hide resolved
// 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.
crisbeto marked this conversation as resolved.
Show resolved Hide resolved
update.insertLeft(current.classDeclaration.getStart(), `@${FALLBACK_DECORATOR}()\n`);
});

tree.commitUpdate(update);
});
}
@@ -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)) {
crisbeto marked this conversation as resolved.
Show resolved Hide resolved
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
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