diff --git a/src/angular/metadata.ts b/src/angular/metadata.ts index 367b06516..88cb08c11 100644 --- a/src/angular/metadata.ts +++ b/src/angular/metadata.ts @@ -55,5 +55,5 @@ export class ModuleMetadata { } export class InjectableMetadata { - constructor(readonly controller: ts.ClassDeclaration, readonly decorator: ts.Decorator) {} + constructor(readonly controller: ts.ClassDeclaration, readonly decorator: ts.Decorator, readonly providedIn?: string | ts.Expression) {} } diff --git a/src/angular/metadataReader.ts b/src/angular/metadataReader.ts index 06d659e64..545c4d0a7 100644 --- a/src/angular/metadataReader.ts +++ b/src/angular/metadataReader.ts @@ -107,7 +107,9 @@ export class MetadataReader { } protected readInjectableMetadata(d: ts.ClassDeclaration, dec: ts.Decorator): DirectiveMetadata { - return new InjectableMetadata(d, dec); + const providedInExpression = getDecoratorPropertyInitializer(dec, 'providedIn'); + + return new InjectableMetadata(d, dec, providedInExpression); } protected readComponentMetadata(d: ts.ClassDeclaration, dec: ts.Decorator): ComponentMetadata { diff --git a/src/index.ts b/src/index.ts index cf4f790cb..411b0a54e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export { Rule as TemplateNoNegatedAsyncRule } from './templateNoNegatedAsyncRule export { Rule as TemplateUseTrackByFunctionRule } from './templateUseTrackByFunctionRule'; export { Rule as UseComponentSelectorRule } from './useComponentSelectorRule'; export { Rule as UseComponentViewEncapsulationRule } from './useComponentViewEncapsulationRule'; +export { Rule as UseInjectableProvidedInRule } from './useInjectableProvidedInRule'; export { Rule as UseLifecycleInterfaceRule } from './useLifecycleInterfaceRule'; export { Rule as UsePipeDecoratorRule } from './usePipeDecoratorRule'; export { Rule as UsePipeTransformInterfaceRule } from './usePipeTransformInterfaceRule'; diff --git a/src/useInjectableProvidedInRule.ts b/src/useInjectableProvidedInRule.ts new file mode 100644 index 000000000..94d005229 --- /dev/null +++ b/src/useInjectableProvidedInRule.ts @@ -0,0 +1,38 @@ +import { IRuleMetadata, RuleFailure } from 'tslint'; +import { AbstractRule } from 'tslint/lib/rules'; +import { SourceFile } from 'typescript'; +import { InjectableMetadata } from './angular'; +import { NgWalker } from './angular/ngWalker'; + +export class Rule extends AbstractRule { + static readonly metadata: IRuleMetadata = { + description: "Enforces classes decorated with @Injectable to use the 'providedIn' property.", + options: null, + optionsDescription: 'Not configurable.', + rationale: "Using the 'providedIn' property makes classes decorated with @Injectable tree shakeable.", + ruleName: 'use-injectable-provided-in', + type: 'functionality', + typescriptOnly: true + }; + + static readonly FAILURE_STRING = "Classes decorated with @Injectable should use the 'providedIn' property"; + + apply(sourceFile: SourceFile): RuleFailure[] { + const walker = new Walker(sourceFile, this.getOptions()); + + return this.applyWithWalker(walker); + } +} + +class Walker extends NgWalker { + protected visitNgInjectable(metadata: InjectableMetadata): void { + this.validateInjectable(metadata); + super.visitNgInjectable(metadata); + } + + private validateInjectable(metadata: InjectableMetadata): void { + if (metadata.providedIn) return; + + this.addFailureAtNode(metadata.decorator, Rule.FAILURE_STRING); + } +} diff --git a/test/useInjectableProvidedInRule.spec.ts b/test/useInjectableProvidedInRule.spec.ts new file mode 100644 index 000000000..c5e14699a --- /dev/null +++ b/test/useInjectableProvidedInRule.spec.ts @@ -0,0 +1,46 @@ +import { Rule } from '../src/useInjectableProvidedInRule'; +import { assertAnnotated, assertSuccess } from './testHelper'; + +const { + metadata: { ruleName }, + FAILURE_STRING +} = Rule; + +describe(ruleName, () => { + describe('failures', () => { + it('should fail if providedIn property is not set', () => { + const source = ` + @Injectable() + ~~~~~~~~~~~~~ + class Test {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + }); + + describe('success', () => { + it('should succeed if providedIn property is set to a literal string', () => { + const source = ` + @Injectable({ + providedIn: 'root' + }) + class Test {} + `; + assertSuccess(ruleName, source); + }); + + it('should succeed if providedIn property is set to a module', () => { + const source = ` + @Injectable({ + providedIn: SomeModule + }) + class Test {} + `; + assertSuccess(ruleName, source); + }); + }); +});