From 77a5e3293b476e5ad9662e29b6e99111caed0fa3 Mon Sep 17 00:00:00 2001 From: Rafael Santana Date: Wed, 13 Feb 2019 20:01:02 -0300 Subject: [PATCH] feat: add template-no-any rule (#755) --- src/index.ts | 1 + src/templateNoAnyRule.ts | 58 ++++++++++ test/templateNoAnyRule.spec.ts | 202 +++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/templateNoAnyRule.ts create mode 100644 test/templateNoAnyRule.spec.ts diff --git a/src/index.ts b/src/index.ts index d20b4449e..e5782e32d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export { Rule as PreferOutputReadonlyRule } from './preferOutputReadonlyRule'; export { Rule as TemplateConditionalComplexityRule } from './templateConditionalComplexityRule'; export { Rule as TemplateCyclomaticComplexityRule } from './templateCyclomaticComplexityRule'; export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateAccessibilityTabindexNoPositiveRule'; +export { Rule as TemplateNoAnyRule } from './templateNoAnyRule'; export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule'; export { Rule as TemplateAccessibilityValidAriaRule } from './templateAccessibilityValidAriaRule'; export { Rule as TemplatesAccessibilityAnchorContentRule } from './templateAccessibilityAnchorContentRule'; diff --git a/src/templateNoAnyRule.ts b/src/templateNoAnyRule.ts new file mode 100644 index 000000000..1a0448a7e --- /dev/null +++ b/src/templateNoAnyRule.ts @@ -0,0 +1,58 @@ +import { MethodCall, PropertyRead } from '@angular/compiler'; +import { IRuleMetadata, RuleFailure } from 'tslint'; +import { AbstractRule } from 'tslint/lib/rules'; +import { dedent } from 'tslint/lib/utils'; +import { SourceFile } from 'typescript'; +import { NgWalker } from './angular/ngWalker'; +import { RecursiveAngularExpressionVisitor } from './angular/templates/recursiveAngularExpressionVisitor'; + +const ANY_TYPE_CAST_FUNCTION_NAME = '$any'; + +export class Rule extends AbstractRule { + static readonly metadata: IRuleMetadata = { + description: `Disallows using '${ANY_TYPE_CAST_FUNCTION_NAME}' in templates.`, + options: null, + optionsDescription: 'Not configurable.', + rationale: dedent` + The use of '${ANY_TYPE_CAST_FUNCTION_NAME}' nullifies the compile-time + benefits of the Angular's type system. + `, + ruleName: 'template-no-any', + type: 'functionality', + typescriptOnly: true + }; + + static readonly FAILURE_STRING = `Avoid using '${ANY_TYPE_CAST_FUNCTION_NAME}' in templates`; + + apply(sourceFile: SourceFile): RuleFailure[] { + return this.applyWithWalker( + new NgWalker(sourceFile, this.getOptions(), { + expressionVisitorCtrl: ExpressionVisitor + }) + ); + } +} + +class ExpressionVisitor extends RecursiveAngularExpressionVisitor { + visitMethodCall(ast: MethodCall, context: any): any { + this.validateMethodCall(ast); + super.visitMethodCall(ast, context); + } + + private generateFailure(ast: MethodCall): void { + const { + span: { end: endSpan, start: startSpan } + } = ast; + + this.addFailureFromStartToEnd(startSpan, endSpan, Rule.FAILURE_STRING); + } + + private validateMethodCall(ast: MethodCall): void { + const isAnyTypeCastFunction = ast.name === ANY_TYPE_CAST_FUNCTION_NAME; + const isAngularAnyTypeCastFunction = !(ast.receiver instanceof PropertyRead); + + if (!isAnyTypeCastFunction || !isAngularAnyTypeCastFunction) return; + + this.generateFailure(ast); + } +} diff --git a/test/templateNoAnyRule.spec.ts b/test/templateNoAnyRule.spec.ts new file mode 100644 index 000000000..758377422 --- /dev/null +++ b/test/templateNoAnyRule.spec.ts @@ -0,0 +1,202 @@ +import { Rule } from '../src/templateNoAnyRule'; +import { assertAnnotated, assertMultipleAnnotated, assertSuccess } from './testHelper'; + +const { + FAILURE_STRING, + metadata: { ruleName } +} = Rule; + +describe(ruleName, () => { + describe('failure', () => { + it('should fail with call expression in expression binding', () => { + const source = ` + @Component({ + template: '{{ $any(framework).name }}' + ~~~~~~~~~~~~~~~ + }) + export class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + + it('should fail with call expression using "this"', () => { + const source = ` + @Component({ + template: '{{ this.$any(framework).name }}' + ~~~~~~~~~~~~~~~~~~~~ + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + + it('should fail with call expression in property binding', () => { + const source = ` + @Component({ + template: 'Click here' + ~~~~~~~~~~~~~~~ + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + + it('should fail with call expression in an output handler', () => { + const source = ` + @Component({ + template: '' + ~~~~~~~~~~ + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + + it('should fail for multiple cases', () => { + const source = ` + @Component({ + template: \` + {{ $any(framework).name }} + ~~~~~~~~~~~~~~~ + {{ this.$any(framework).name }} + ^^^^^^^^^^^^^^^^^^^^ + Click here' + ############### + + %%%%%%%%%% + \` + }) + class Bar {} + `; + assertMultipleAnnotated({ + failures: [ + { + char: '~', + msg: FAILURE_STRING + }, + { + char: '^', + msg: FAILURE_STRING + }, + { + char: '#', + msg: FAILURE_STRING + }, + { + char: '%', + msg: FAILURE_STRING + } + ], + ruleName, + source + }); + }); + }); + + describe('success', () => { + it('should pass with no call expression', () => { + const source = ` + @Component({ + template: '{{ $any }}' + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should pass for an object containing a function called "$any"', () => { + const source = ` + @Component({ + template: '{{ obj.$any() }}' + }) + class Bar { + readonly obj = { + $any: () => '$any' + }; + } + `; + assertSuccess(ruleName, source); + }); + + it('should pass for a nested object containing a function called "$any"', () => { + const source = ` + @Component({ + template: '{{ obj?.x?.y!.z!.$any() }}' + }) + class Bar { + readonly obj: Partial = { + x: { + y: { + z: { + $any: () => '$any' + } + } + } + }; + } + `; + assertSuccess(ruleName, source); + }); + + it('should pass with call expression in property binding', () => { + const source = ` + @Component({ + template: 'Click here' + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should pass with call expression in an output handler', () => { + const source = ` + @Component({ + template: '' + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should pass for multiple cases', () => { + const source = ` + @Component({ + template: \` + {{ $any }} + {{ obj?.x?.y!.z!.$any() }} + Click here + + \` + }) + class Bar { + readonly obj: Partial = { + x: { + y: { + z: { + $any: () => '$any' + } + } + } + }; + } + `; + assertSuccess(ruleName, source); + }); + }); +});