From 6274f6afd7ea878172531bf67790e58bf9c6bdbe Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Fri, 6 Jul 2018 19:09:16 -0500 Subject: [PATCH] feat(rules): add ion-option-is-now-ion-select-option rule --- src/helpers/elementRename.ts | 53 ++++++ src/ionOptionIsNowIonSelectOptionRule.ts | 5 + test/ionOptionIsNowIonSelectOption.spec.ts | 198 +++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 src/helpers/elementRename.ts create mode 100644 src/ionOptionIsNowIonSelectOptionRule.ts create mode 100644 test/ionOptionIsNowIonSelectOption.spec.ts diff --git a/src/helpers/elementRename.ts b/src/helpers/elementRename.ts new file mode 100644 index 0000000..120936f --- /dev/null +++ b/src/helpers/elementRename.ts @@ -0,0 +1,53 @@ +import * as ast from '@angular/compiler'; +import { NgWalker } from 'codelyzer/angular/ngWalker'; +import { BasicTemplateAstVisitor } from 'codelyzer/angular/templates/basicTemplateAstVisitor'; +import * as Lint from 'tslint'; +import * as ts from 'typescript'; + +function generateDescription(elementName: string, newElementName: string) { + return `The ${elementName} component is now named ${newElementName}.`; +} + +export function createElementRenameTemplateVisitorClass(elementName: string, newElementName: string) { + return class extends BasicTemplateAstVisitor { + visitElement(element: ast.ElementAst, context: any): any { + if (element.name && element.name === elementName) { + const startTagStart = element.sourceSpan.start.offset; + const startTagLength = element.name.length; + const startTagPosition = this.getSourcePosition(startTagStart) + 1; + const endTagStart = element.endSourceSpan.start.offset; + const endTagLength = element.name.length; + const endTagPosition = this.getSourcePosition(endTagStart) + 2; + + this.addFailureAt(startTagStart + 1, startTagLength, generateDescription(element.name, newElementName), [ + Lint.Replacement.replaceFromTo(startTagPosition, startTagPosition + startTagLength, newElementName), + Lint.Replacement.replaceFromTo(endTagPosition, endTagPosition + endTagLength, newElementName) + ]); + } + + super.visitElement(element, context); + } + }; +} + +export function createElementRenameRuleClass(ruleName: string, elementName: string, newElementName: string) { + return class extends Lint.Rules.AbstractRule { + public static metadata: Lint.IRuleMetadata = { + ruleName: ruleName, + type: 'functionality', + description: generateDescription(elementName, newElementName), + options: null, + optionsDescription: 'Not configurable.', + typescriptOnly: false, + hasFix: true + }; + + public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { + return this.applyWithWalker( + new NgWalker(sourceFile, this.getOptions(), { + templateVisitorCtrl: createElementRenameTemplateVisitorClass(elementName, newElementName) + }) + ); + } + }; +} diff --git a/src/ionOptionIsNowIonSelectOptionRule.ts b/src/ionOptionIsNowIonSelectOptionRule.ts new file mode 100644 index 0000000..306ebbc --- /dev/null +++ b/src/ionOptionIsNowIonSelectOptionRule.ts @@ -0,0 +1,5 @@ +import { createElementRenameRuleClass } from './helpers/elementRename'; + +export const ruleName = 'ion-option-is-now-ion-select-option'; + +export const Rule = createElementRenameRuleClass(ruleName, 'ion-option', 'ion-select-option'); diff --git a/test/ionOptionIsNowIonSelectOption.spec.ts b/test/ionOptionIsNowIonSelectOption.spec.ts new file mode 100644 index 0000000..1beb230 --- /dev/null +++ b/test/ionOptionIsNowIonSelectOption.spec.ts @@ -0,0 +1,198 @@ +import { assertSuccess, assertAnnotated, assertMultipleAnnotated, assertFailures } from './testHelper'; +import { Replacement, Utils } from 'tslint'; +import { expect } from 'chai'; +import { ruleName } from '../src/ionOptionIsNowIonSelectOptionRule'; + +describe(ruleName, () => { + describe('success', () => { + it('should work with proper style', () => { + let source = ` + @Component({ + template: \` + + Option 1 + Option 2 + Option 3 + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + }); + + describe('failure', () => { + it('should fail when ion-option is used', () => { + let source = ` + @Component({ + template: \` + + Option 1 + ~~~~~~~~~~ + Option 2 + ^^^^^^^^^^ + Option 3 + zzzzzzzzzz + + \` + }) + class Bar {} + `; + + assertMultipleAnnotated({ + ruleName, + source, + failures: [ + { + char: '~', + msg: 'The ion-option component is now named ion-select-option.' + }, + { + char: '^', + msg: 'The ion-option component is now named ion-select-option.' + }, + { + char: 'z', + msg: 'The ion-option component is now named ion-select-option.' + } + ] + }); + }); + }); + + describe('replacements', () => { + it('should replace ion-option with ion-select-option', () => { + let source = ` + @Component({ + template: \` + + Option 1 + Option 2 + Option 3 + + \` + }) + class Bar {} + `; + + const failures = assertFailures(ruleName, source, [ + { + message: 'The ion-option component is now named ion-select-option.', + startPosition: { + line: 4, + character: 15 + }, + endPosition: { + line: 4, + character: 25 + } + }, + { + message: 'The ion-option component is now named ion-select-option.', + startPosition: { + line: 5, + character: 15 + }, + endPosition: { + line: 5, + character: 25 + } + }, + { + message: 'The ion-option component is now named ion-select-option.', + startPosition: { + line: 6, + character: 15 + }, + endPosition: { + line: 6, + character: 25 + } + } + ]); + + const fixes = failures.map(f => f.getFix()); + const res = Replacement.applyAll(source, Utils.flatMap(fixes, Utils.arrayify)); + + let expected = ` + @Component({ + template: \` + + Option 1 + Option 2 + Option 3 + + \` + }) + class Bar {} + `; + + expect(res).to.eq(expected); + }); + + it('should replace ion-option with ion-select-option on multiline', () => { + let source = ` + @Component({ + template: \` + + + Female + + + Male + + + \` + }) + class Bar {} + `; + + const failures = assertFailures(ruleName, source, [ + { + message: 'The ion-option component is now named ion-select-option.', + startPosition: { + line: 4, + character: 15 + }, + endPosition: { + line: 4, + character: 25 + } + }, + { + message: 'The ion-option component is now named ion-select-option.', + startPosition: { + line: 7, + character: 15 + }, + endPosition: { + line: 7, + character: 25 + } + } + ]); + + const fixes = failures.map(f => f.getFix()); + const res = Replacement.applyAll(source, Utils.flatMap(fixes, Utils.arrayify)); + + let expected = ` + @Component({ + template: \` + + + Female + + + Male + + + \` + }) + class Bar {} + `; + + expect(res).to.eq(expected); + }); + }); +});