/
padding.ts
356 lines (315 loc) · 10.2 KB
/
padding.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
/**
* Require/fix newlines around jest functions
*
* Based on eslint/padding-line-between-statements by Toru Nagashima
* See: https://github.com/eslint/eslint/blob/master/lib/rules/padding-line-between-statements.js
*
* Some helpers borrowed from eslint ast-utils by Gyandeep Singh
* See: https://github.com/eslint/eslint/blob/master/lib/rules/utils/ast-utils.js
*/
import { AST, Rule, SourceCode } from 'eslint';
// This is because we are using @types/estree that are brought in with eslint
// eslint-disable-next-line import/no-extraneous-dependencies
import { Node } from 'estree';
import * as astUtils from '../ast-utils';
// Statement types we'll respond to
export const enum StatementType {
Any,
AfterAllToken,
AfterEachToken,
BeforeAllToken,
BeforeEachToken,
DescribeToken,
ExpectToken,
FdescribeToken,
FitToken,
ItToken,
TestToken,
XdescribeToken,
XitToken,
XtestToken,
}
type StatementTypes = StatementType | StatementType[];
type StatementTester = (node: Node, sourceCode: SourceCode) => boolean;
// Padding type to apply between statements
export const enum PaddingType {
Any,
Always,
}
type PaddingTester = (
prevNode: Node,
nextNode: Node,
paddingContext: PaddingContext,
) => void;
// A configuration object for padding type and the two statement types
export interface Config {
paddingType: PaddingType;
prevStatementType: StatementTypes;
nextStatementType: StatementTypes;
}
// Tracks position in scope and prevNode. Used to compare current and prev node
// and then to walk back up to the parent scope or down into the next one.
// And so on...
interface Scope {
upper: Scope | null;
prevNode: Node | null;
}
interface ScopeInfo {
prevNode: Node | null;
enter: () => void;
exit: () => void;
}
interface PaddingContext {
ruleContext: Rule.RuleContext;
sourceCode: SourceCode;
scopeInfo: ScopeInfo;
configs: Config[];
}
// Creates a StatementTester to test an ExpressionStatement's first token name
const createTokenTester = (tokenName: string): StatementTester => {
return (node: Node, sourceCode: SourceCode): boolean => {
let activeNode = node;
if (activeNode.type === 'ExpressionStatement') {
// In the case of `await`, we actually care about its argument
if (activeNode.expression.type === 'AwaitExpression') {
activeNode = activeNode.expression.argument;
}
const token = sourceCode.getFirstToken(activeNode);
return token.type === 'Identifier' && token.value === tokenName;
}
return false;
};
};
// A mapping of StatementType to StatementTester for... testing statements
const statementTesters: { [T in StatementType]: StatementTester } = {
[StatementType.Any]: () => true,
[StatementType.AfterAllToken]: createTokenTester('afterAll'),
[StatementType.AfterEachToken]: createTokenTester('afterEach'),
[StatementType.BeforeAllToken]: createTokenTester('beforeAll'),
[StatementType.BeforeEachToken]: createTokenTester('beforeEach'),
[StatementType.DescribeToken]: createTokenTester('describe'),
[StatementType.ExpectToken]: createTokenTester('expect'),
[StatementType.FdescribeToken]: createTokenTester('fdescribe'),
[StatementType.FitToken]: createTokenTester('fit'),
[StatementType.ItToken]: createTokenTester('it'),
[StatementType.TestToken]: createTokenTester('test'),
[StatementType.XdescribeToken]: createTokenTester('xdescribe'),
[StatementType.XitToken]: createTokenTester('xit'),
[StatementType.XtestToken]: createTokenTester('xtest'),
};
/**
* Check and report statements for `PaddingType.Always` configuration.
* This autofix inserts a blank line between the given 2 statements.
* If the `prevNode` has trailing comments, it inserts a blank line after the
* trailing comments.
*/
const paddingAlwaysTester = (
prevNode: Node,
nextNode: Node,
paddingContext: PaddingContext,
): void => {
const { sourceCode, ruleContext } = paddingContext;
const paddingLines = astUtils.getPaddingLineSequences(
prevNode,
nextNode,
sourceCode,
);
// We've got some padding lines. Great.
if (paddingLines.length > 0) {
return;
}
// Missing padding line
ruleContext.report({
node: nextNode,
message: 'Expected blank line before this statement.',
fix(fixer: Rule.RuleFixer): Rule.Fix {
let prevToken = astUtils.getActualLastToken(sourceCode, prevNode);
const nextToken = (sourceCode.getFirstTokenBetween(prevToken, nextNode, {
includeComments: true,
/**
* Skip the trailing comments of the previous node.
* This inserts a blank line after the last trailing comment.
*
* For example:
*
* foo(); // trailing comment.
* // comment.
* bar();
*
* Get fixed to:
*
* foo(); // trailing comment.
*
* // comment.
* bar();
*/
filter(token: AST.Token): boolean {
if (astUtils.areTokensOnSameLine(prevToken, token)) {
prevToken = token;
return false;
}
return true;
},
}) || nextNode) as AST.Token;
const insertText = astUtils.areTokensOnSameLine(prevToken, nextToken)
? '\n\n'
: '\n';
return fixer.insertTextAfter(prevToken, insertText);
},
});
};
// A mapping of PaddingType to PaddingTester
const paddingTesters: { [T in PaddingType]: PaddingTester } = {
[PaddingType.Any]: () => true,
[PaddingType.Always]: paddingAlwaysTester,
};
const createScopeInfo = (): ScopeInfo => {
return (() => {
let scope: Scope = null;
return {
get prevNode() {
return scope.prevNode;
},
set prevNode(node) {
scope.prevNode = node;
},
enter() {
scope = { upper: scope, prevNode: null };
},
exit() {
scope = scope.upper;
},
};
})();
};
/**
* Check whether the given node matches the statement type
*/
const nodeMatchesType = (
node: Node,
statementType: StatementTypes,
paddingContext: PaddingContext,
): boolean => {
let innerStatementNode = node;
const { sourceCode } = paddingContext;
// Dig into LabeledStatement body until it's not that anymore
while (innerStatementNode.type === 'LabeledStatement') {
innerStatementNode = innerStatementNode.body;
}
// If it's an array recursively check if any of the statement types match
// the node
if (Array.isArray(statementType)) {
return statementType.some((type) =>
nodeMatchesType(innerStatementNode, type, paddingContext),
);
}
return statementTesters[statementType](innerStatementNode, sourceCode);
};
/**
* Executes matching padding tester for last matched padding config for given
* nodes
*/
const testPadding = (
prevNode: Node,
nextNode: Node,
paddingContext: PaddingContext,
): void => {
const { configs } = paddingContext;
const testType = (type: PaddingType) =>
paddingTesters[type](prevNode, nextNode, paddingContext);
for (let i = configs.length - 1; i >= 0; --i) {
const {
prevStatementType: prevType,
nextStatementType: nextType,
paddingType,
} = configs[i];
if (
nodeMatchesType(prevNode, prevType, paddingContext) &&
nodeMatchesType(nextNode, nextType, paddingContext)
) {
return testType(paddingType);
}
}
// There were no matching padding rules for the prevNode, nextNode,
// paddingType combination... so we'll use PaddingType.Any which is always ok
return testType(PaddingType.Any);
};
/**
* Verify padding lines between the given node and the previous node.
*/
const verifyNode = (node: Node, paddingContext: PaddingContext): void => {
const { scopeInfo } = paddingContext;
// NOTE: ESLint types use ESTree which provides a Node type, however
// ESTree.Node doesn't support the parent property which is added by
// ESLint during traversal. Our best bet is to ignore the property access
// here as it's the only place that it's checked.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!astUtils.isValidParent((node as any).parent.type)) {
return;
}
if (scopeInfo.prevNode) {
testPadding(scopeInfo.prevNode, node, paddingContext);
}
scopeInfo.prevNode = node;
};
/**
* Creates an ESLint rule for a given set of padding Config objects.
*
* The algorithm is approximately this:
*
* For each 'scope' in the program
* - Enter the scope (store the parent scope and previous node)
* - For each statement in the scope
* - Check the current node and previous node against the Config objects
* - If the current node and previous node match a Config, check the padding.
* Otherwise, ignore it.
* - If the padding is missing (and required), report and fix
* - Store the current node as the previous
* - Repeat
* - Exit scope (return to parent scope and clear previous node)
*
* The items we're looking for with this rule are ExpressionStatement nodes
* where the first token is an Identifier with a name matching one of the Jest
* functions. It's not foolproof, of course, but it's probably good enough for
* almost all cases.
*
* The Config objects specify a padding type, a previous statement type, and a
* next statement type. Wildcard statement types and padding types are
* supported. The current node and previous node are checked against the
* statement types. If they match then the specified padding type is
* tested/enforced.
*
* See src/index.ts for examples of Config usage.
*/
export const createRule = (
configs: Config[],
deprecated = false,
): Rule.RuleModule => ({
meta: {
fixable: 'whitespace',
deprecated,
},
create(context: Rule.RuleContext) {
const paddingContext = {
ruleContext: context,
sourceCode: context.getSourceCode(),
scopeInfo: createScopeInfo(),
configs,
};
const { scopeInfo } = paddingContext;
return {
Program: scopeInfo.enter,
'Program:exit': scopeInfo.enter,
BlockStatement: scopeInfo.enter,
'BlockStatement:exit': scopeInfo.exit,
SwitchStatement: scopeInfo.enter,
'SwitchStatement:exit': scopeInfo.exit,
':statement': (node: Node) => verifyNode(node, paddingContext),
SwitchCase: (node: Node) => {
verifyNode(node, paddingContext);
scopeInfo.enter();
},
'SwitchCase:exit': scopeInfo.exit,
};
},
});