Skip to content

Commit 5d5e21d

Browse files
rafaelss95mgechev
authored andcommitted
feat(rule): add prefer-inline-decorator rule (#586)
1 parent 43d415a commit 5d5e21d

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

src/preferInlineDecoratorRule.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { IOptions, IRuleMetadata, Replacement, RuleFailure, Rules } from 'tslint/lib';
2+
import { isSameLine } from 'tsutils';
3+
import { Decorator, Node, PropertyAccessExpression, SourceFile } from 'typescript';
4+
5+
import { NgWalker } from './angular/ngWalker';
6+
import { getDecoratorName } from './util/utils';
7+
8+
export class Rule extends Rules.AbstractRule {
9+
static readonly metadata: IRuleMetadata = {
10+
description: 'Ensures that decorators are on the same line as the property/method it decorates.',
11+
descriptionDetails: 'See more at https://angular.io/guide/styleguide#style-05-12.',
12+
hasFix: true,
13+
optionExamples: ['true', '[true, "HostListener"]'],
14+
options: {
15+
items: {
16+
type: 'string'
17+
},
18+
type: 'array'
19+
},
20+
optionsDescription: 'A list of blacklisted decorators.',
21+
rationale: 'Placing the decorator on the same line usually makes for shorter code and still easily identifies the property/method.',
22+
ruleName: 'prefer-inline-decorator',
23+
type: 'style',
24+
typescriptOnly: true
25+
};
26+
27+
static readonly FAILURE_STRING = 'Consider placing decorators on the same line as the property/method it decorates';
28+
29+
apply(sourceFile: SourceFile): RuleFailure[] {
30+
return this.applyWithWalker(new PreferInlineDecoratorWalker(sourceFile, this.getOptions()));
31+
}
32+
}
33+
34+
type DecoratorKeys =
35+
| 'ContentChild'
36+
| 'ContentChildren'
37+
| 'HostBinding'
38+
| 'HostListener'
39+
| 'Input'
40+
| 'Output'
41+
| 'ViewChild'
42+
| 'ViewChildren';
43+
44+
export const decoratorKeys = new Set<DecoratorKeys>([
45+
'ContentChild',
46+
'ContentChildren',
47+
'HostBinding',
48+
'HostListener',
49+
'Input',
50+
'Output',
51+
'ViewChild',
52+
'ViewChildren'
53+
]);
54+
55+
export class PreferInlineDecoratorWalker extends NgWalker {
56+
private readonly blacklistedDecorators: typeof decoratorKeys;
57+
58+
constructor(source: SourceFile, options: IOptions) {
59+
super(source, options);
60+
this.blacklistedDecorators = new Set<DecoratorKeys>(options.ruleArguments.slice(1));
61+
}
62+
63+
protected visitMethodDecorator(decorator: Decorator) {
64+
this.validateDecorator(decorator, decorator.parent);
65+
super.visitMethodDecorator(decorator);
66+
}
67+
68+
protected visitPropertyDecorator(decorator: Decorator) {
69+
this.validateDecorator(decorator, decorator.parent);
70+
super.visitPropertyDecorator(decorator);
71+
}
72+
73+
private validateDecorator(decorator: Decorator, property: Node) {
74+
const decoratorName: DecoratorKeys = getDecoratorName(decorator);
75+
const isDecoratorBlacklisted = this.blacklistedDecorators.has(decoratorName);
76+
77+
if (isDecoratorBlacklisted) {
78+
return;
79+
}
80+
81+
const decoratorStartPos = decorator.getStart();
82+
const propertyStartPos = (property as PropertyAccessExpression).name.getStart();
83+
84+
if (isSameLine(this.getSourceFile(), decoratorStartPos, propertyStartPos)) {
85+
return;
86+
}
87+
88+
const fix = Replacement.deleteFromTo(decorator.getEnd(), propertyStartPos - 1);
89+
this.addFailureAt(decoratorStartPos, property.getWidth(), Rule.FAILURE_STRING, fix);
90+
}
91+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { expect } from 'chai';
2+
import { Replacement } from 'tslint/lib';
3+
4+
import { decoratorKeys, Rule } from '../src/preferInlineDecoratorRule';
5+
import { assertFailure, assertFailures, assertSuccess, IExpectedFailure } from './testHelper';
6+
7+
const {
8+
FAILURE_STRING,
9+
metadata: { ruleName }
10+
} = Rule;
11+
const className = 'Test';
12+
13+
describe(ruleName, () => {
14+
describe('failure', () => {
15+
const expectedFailure: IExpectedFailure = {
16+
endPosition: {
17+
character: 35,
18+
line: 3
19+
},
20+
message: FAILURE_STRING,
21+
startPosition: {
22+
character: 14,
23+
line: 2
24+
}
25+
};
26+
27+
decoratorKeys.forEach(decoratorKey => {
28+
describe(decoratorKey, () => {
29+
it('should fail when a property does not start on the same line as the decoratorKey', () => {
30+
const source = `
31+
class ${className} {
32+
@${decoratorKey}('childTest')
33+
childTest: ChildTest;
34+
}
35+
`;
36+
assertFailure(ruleName, source, expectedFailure);
37+
});
38+
39+
it('should fail and apply proper replacements when a property does not start on the same line as the decoratorKey', () => {
40+
const source = `
41+
class ${className} {
42+
@${decoratorKey}('childTest')
43+
childTest: ChildTest;
44+
}
45+
`;
46+
const failures = assertFailure(ruleName, source, expectedFailure);
47+
const replacement = Replacement.applyFixes(source, failures.map(f => f.getFix()));
48+
49+
expect(replacement).to.eq(`
50+
class ${className} {
51+
@${decoratorKey}('childTest') childTest: ChildTest;
52+
}
53+
`);
54+
});
55+
});
56+
});
57+
58+
describe('blacklist', () => {
59+
it('should fail when a property does not start on the same line as the decoratorKey and is not present on blacklist options', () => {
60+
const [firstDecorator, ...restDecorators] = Array.from(decoratorKeys);
61+
const source = `
62+
class ${className} {
63+
@${firstDecorator}()
64+
test = new EventEmitter<void>();
65+
}
66+
`;
67+
assertFailure(
68+
ruleName,
69+
source,
70+
{
71+
endPosition: {
72+
character: 44,
73+
line: 3
74+
},
75+
message: FAILURE_STRING,
76+
startPosition: {
77+
character: 12,
78+
line: 2
79+
}
80+
},
81+
[true, restDecorators]
82+
);
83+
});
84+
});
85+
86+
it('should fail when there are multiple properties that does not start on the same line as the decoratorKey', () => {
87+
const source = `
88+
class ${className} {
89+
@Input('childTest')
90+
childTest: ChildTest;
91+
@Input('foo')
92+
foo: Foo;
93+
}
94+
`;
95+
assertFailures(ruleName, source, [
96+
{
97+
endPosition: {
98+
character: 31,
99+
line: 3
100+
},
101+
message: FAILURE_STRING,
102+
startPosition: {
103+
character: 10,
104+
line: 2
105+
}
106+
},
107+
{
108+
endPosition: {
109+
character: 19,
110+
line: 5
111+
},
112+
message: FAILURE_STRING,
113+
startPosition: {
114+
character: 10,
115+
line: 4
116+
}
117+
}
118+
]);
119+
});
120+
});
121+
122+
describe('success', () => {
123+
decoratorKeys.forEach(decoratorKey => {
124+
describe(decoratorKey, () => {
125+
it('should succeed when a property starts and ends on the same line as the decoratorKey', () => {
126+
const source = `
127+
class ${className} {
128+
@${decoratorKey}('childTest') childTest123: ChildTest;
129+
}
130+
`;
131+
assertSuccess(ruleName, source);
132+
});
133+
134+
it('should succeed when a property starts on the same line as the decoratorKey and ends on another line', () => {
135+
const source = `
136+
class ${className} {
137+
@${decoratorKey}('childTest') childTest123: ChildTest =
138+
veryVeryVeryVeryVeryVeryVeryLongDefaultVariable;
139+
}
140+
`;
141+
assertSuccess(ruleName, source);
142+
});
143+
});
144+
});
145+
146+
describe('blacklist', () => {
147+
it('should succeed when a property starts on another line and is present on blacklist options', () => {
148+
const [firstDecorator] = Array.from(decoratorKeys);
149+
const source = `
150+
class ${className} {
151+
@${firstDecorator}()
152+
test = new EventEmitter<void>();
153+
}
154+
`;
155+
assertSuccess(ruleName, source, [true, firstDecorator]);
156+
});
157+
});
158+
159+
describe('special cases', () => {
160+
it('should succeed when getters starts on the same line as the decoratorKey and ends on another line', () => {
161+
const source = `
162+
class ${className} {
163+
@Input() get test(): string {
164+
return this._test;
165+
}
166+
private _test: string;
167+
}
168+
`;
169+
assertSuccess(ruleName, source);
170+
});
171+
172+
it('should succeed when setters starts on the same line as the decoratorKey and ends on another line', () => {
173+
const source = `
174+
class ${className} {
175+
@Input() set test(value: string) {
176+
this._test = value;
177+
}
178+
private _test: string;
179+
}
180+
`;
181+
assertSuccess(ruleName, source);
182+
});
183+
184+
it('should succeed for getters and setters on another line', () => {
185+
const source = `
186+
class ${className} {
187+
@Input()
188+
get test(): string {
189+
return this._test;
190+
}
191+
set test(value: string) {
192+
this._test = value;
193+
}
194+
private _test: string;
195+
}
196+
`;
197+
assertSuccess(ruleName, source);
198+
});
199+
});
200+
});
201+
});

0 commit comments

Comments
 (0)