Skip to content

Commit 0a19cb4

Browse files
authored
consistent-export-decorator-position: Fix decorator position reporting (#3583)
1 parent 78c5abf commit 0a19cb4

4 files changed

Lines changed: 243 additions & 24 deletions

rules/consistent-export-decorator-position.js

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ const STYLE_ABOVE = 'above';
22
const STYLE_BEFORE = 'before';
33
const STYLE_AFTER = 'after';
44
const STYLE_MIXED = 'mixed';
5-
const styles = new Set([
5+
const styles = [
66
STYLE_ABOVE,
77
STYLE_BEFORE,
88
STYLE_AFTER,
9-
]);
9+
];
1010

1111
const MESSAGE_ID = 'consistent-export-decorator-position';
1212
const messages = {
1313
[MESSAGE_ID]: 'Expected export decorators to be positioned `{{expected}}`, but found `{{actual}}`.',
1414
};
1515

1616
const isKeywordToken = keyword => token => token.type === 'Keyword' && token.value === keyword;
17+
const isClassKeywordToken = isKeywordToken('class');
1718
const isClassDeclarationWithDecorators = node =>
1819
node?.type === 'ClassDeclaration'
1920
&& node.decorators?.length > 0;
@@ -44,23 +45,30 @@ const getExportText = (exportDeclaration, sourceCode) => {
4445
return sourceCode.text.slice(exportStart, defaultEnd);
4546
};
4647

47-
const getActualStyle = ({decorators, exportToken, context}) => {
48-
const {sourceCode} = context;
49-
const isAfterExport = decorator => sourceCode.getRange(decorator)[0] > sourceCode.getRange(exportToken)[0];
50-
51-
if (decorators.some(decorator => isAfterExport(decorator))) {
52-
return decorators.every(decorator => isAfterExport(decorator))
53-
? STYLE_AFTER
54-
: STYLE_MIXED;
55-
}
56-
57-
const exportStartLine = sourceCode.getLoc(exportToken).start.line;
58-
if (sourceCode.getLoc(decorators.at(-1)).end.line !== exportStartLine) {
59-
return STYLE_ABOVE;
48+
const getDecoratorStyle = ({decorator, exportToken, sourceCode}) => {
49+
if (sourceCode.getRange(decorator)[0] > sourceCode.getRange(exportToken)[0]) {
50+
return STYLE_AFTER;
6051
}
6152

62-
return decorators.every(decorator => sourceCode.getLoc(decorator).end.line === exportStartLine)
53+
const exportLine = sourceCode.getLoc(exportToken).start.line;
54+
return sourceCode.getLoc(decorator).end.line === exportLine
6355
? STYLE_BEFORE
56+
: STYLE_ABOVE;
57+
};
58+
59+
const getActualStyle = ({decorators, exportToken, sourceCode}) => {
60+
const firstDecoratorStyle = getDecoratorStyle({
61+
decorator: decorators[0],
62+
exportToken,
63+
sourceCode,
64+
});
65+
66+
return decorators.every(decorator => getDecoratorStyle({
67+
decorator,
68+
exportToken,
69+
sourceCode,
70+
}) === firstDecoratorStyle)
71+
? firstDecoratorStyle
6472
: STYLE_MIXED;
6573
};
6674

@@ -110,23 +118,29 @@ const getProblem = ({exportDeclaration, expectedStyle, context}) => {
110118
const actualStyle = getActualStyle({
111119
decorators,
112120
exportToken,
113-
context,
121+
sourceCode,
114122
});
115123

116124
if (actualStyle === expectedStyle) {
117125
return;
118126
}
119127

128+
const reportNode = decorators.find(decorator => getDecoratorStyle({
129+
decorator,
130+
exportToken,
131+
sourceCode,
132+
}) !== expectedStyle) ?? decorators[0];
133+
120134
const problem = {
121-
node: decorators[0],
135+
node: reportNode,
122136
messageId: MESSAGE_ID,
123137
data: {
124138
expected: expectedStyle,
125139
actual: actualStyle,
126140
},
127141
};
128142

129-
const classToken = sourceCode.getFirstToken(declaration, isKeywordToken('class'));
143+
const classToken = sourceCode.getTokenAfter(decorators.at(-1), isClassKeywordToken);
130144
const defaultToken = exportDeclaration.type === 'ExportDefaultDeclaration'
131145
? sourceCode.getTokenAfter(exportToken, isKeywordToken('default'))
132146
: undefined;
@@ -179,7 +193,7 @@ const create = context => {
179193

180194
const schema = [
181195
{
182-
enum: [...styles],
196+
enum: styles,
183197
description: 'Decorator position relative to the export declaration.',
184198
},
185199
];

test/consistent-export-decorator-position.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,208 @@ test.snapshot({
171171
`,
172172
].map(testCase => withTypescriptParser(testCase)),
173173
});
174+
175+
test({
176+
valid: [],
177+
invalid: [
178+
{
179+
code: '@foo export @bar @baz class Foo {}',
180+
output: '@foo @bar @baz export class Foo {}',
181+
options: ['before'],
182+
errors: [
183+
{
184+
messageId: 'consistent-export-decorator-position',
185+
line: 1,
186+
column: 13,
187+
endColumn: 17,
188+
},
189+
],
190+
},
191+
{
192+
code: 'export default @foo(class {}) class Foo {}',
193+
output: '@foo(class {})\nexport default class Foo {}',
194+
errors: [
195+
{
196+
messageId: 'consistent-export-decorator-position',
197+
line: 1,
198+
column: 16,
199+
endColumn: 30,
200+
},
201+
],
202+
},
203+
{
204+
code: 'export @foo(class {}) class Foo {}',
205+
output: '@foo(class {}) export class Foo {}',
206+
options: ['before'],
207+
errors: [
208+
{
209+
messageId: 'consistent-export-decorator-position',
210+
line: 1,
211+
column: 8,
212+
endColumn: 22,
213+
},
214+
],
215+
},
216+
{
217+
code: '@foo(class {})\nexport class Foo {}',
218+
output: 'export @foo(class {}) class Foo {}',
219+
options: ['after'],
220+
errors: [
221+
{
222+
messageId: 'consistent-export-decorator-position',
223+
line: 1,
224+
column: 1,
225+
endColumn: 15,
226+
},
227+
],
228+
},
229+
{
230+
code: '@foo(class {})\nexport default class Foo {}',
231+
output: 'export default @foo(class {}) class Foo {}',
232+
options: ['after'],
233+
errors: [
234+
{
235+
messageId: 'consistent-export-decorator-position',
236+
line: 1,
237+
column: 1,
238+
endColumn: 15,
239+
},
240+
],
241+
},
242+
{
243+
code: '@foo\nexport @bar class Foo {}',
244+
output: '@foo\n@bar\nexport class Foo {}',
245+
errors: [
246+
{
247+
messageId: 'consistent-export-decorator-position',
248+
line: 2,
249+
column: 8,
250+
endColumn: 12,
251+
},
252+
],
253+
},
254+
{
255+
code: '@foo\nexport @bar class Foo {}',
256+
output: '@foo @bar export class Foo {}',
257+
options: ['before'],
258+
errors: [
259+
{
260+
messageId: 'consistent-export-decorator-position',
261+
line: 1,
262+
column: 1,
263+
endColumn: 5,
264+
},
265+
],
266+
},
267+
{
268+
code: '@foo\nexport @bar class Foo {}',
269+
output: 'export @foo @bar class Foo {}',
270+
options: ['after'],
271+
errors: [
272+
{
273+
messageId: 'consistent-export-decorator-position',
274+
line: 1,
275+
column: 1,
276+
endColumn: 5,
277+
},
278+
],
279+
},
280+
{
281+
code: '@foo /* comment */ export @bar class Foo {}',
282+
options: ['before'],
283+
errors: [
284+
{
285+
messageId: 'consistent-export-decorator-position',
286+
line: 1,
287+
column: 27,
288+
endColumn: 31,
289+
},
290+
],
291+
},
292+
{
293+
code: '@foo /* keep */ @bar export class Foo {}',
294+
errors: [
295+
{
296+
messageId: 'consistent-export-decorator-position',
297+
line: 1,
298+
column: 1,
299+
endColumn: 5,
300+
},
301+
],
302+
},
303+
{
304+
code: '/* comment */ @decorator export class Foo {}',
305+
output: '/* comment */ @decorator\nexport class Foo {}',
306+
errors: [
307+
{
308+
messageId: 'consistent-export-decorator-position',
309+
line: 1,
310+
column: 15,
311+
endColumn: 25,
312+
},
313+
],
314+
},
315+
{
316+
code: 'foo(); @decorator export class Foo {}',
317+
output: 'foo(); @decorator\nexport class Foo {}',
318+
errors: [
319+
{
320+
messageId: 'consistent-export-decorator-position',
321+
line: 1,
322+
column: 8,
323+
endColumn: 18,
324+
},
325+
],
326+
},
327+
{
328+
code: 'namespace N { const value = 1; @decorator export class Foo {} }',
329+
output: 'namespace N { const value = 1; @decorator\nexport class Foo {} }',
330+
errors: [
331+
{
332+
messageId: 'consistent-export-decorator-position',
333+
line: 1,
334+
column: 32,
335+
endColumn: 42,
336+
},
337+
],
338+
},
339+
{
340+
code: 'namespace N { @decorator export class Bar {} }',
341+
output: 'namespace N { @decorator\nexport class Bar {} }',
342+
errors: [
343+
{
344+
messageId: 'consistent-export-decorator-position',
345+
line: 1,
346+
column: 15,
347+
endColumn: 25,
348+
},
349+
],
350+
},
351+
{
352+
code: 'namespace N {\n\t@decorator export class Foo {}\n}',
353+
output: 'namespace N {\n\texport @decorator class Foo {}\n}',
354+
options: ['after'],
355+
errors: [
356+
{
357+
messageId: 'consistent-export-decorator-position',
358+
line: 2,
359+
column: 2,
360+
endColumn: 12,
361+
},
362+
],
363+
},
364+
{
365+
code: 'namespace N { @decorator export default class Foo {} }',
366+
output: 'namespace N { export default @decorator class Foo {} }',
367+
options: ['after'],
368+
errors: [
369+
{
370+
messageId: 'consistent-export-decorator-position',
371+
line: 1,
372+
column: 15,
373+
endColumn: 25,
374+
},
375+
],
376+
},
377+
].map(testCase => withTypescriptParser(testCase)),
378+
});

test/snapshots/consistent-export-decorator-position.js.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,9 @@ Generated by [AVA](https://avajs.dev).
207207
208208
`␊
209209
Message:␊
210-
> 1 | @foo␊
210+
1 | @foo␊
211+
> 2 | @bar export class Foo {}␊
211212
| ^^^^ Expected export decorators to be positioned \`above\`, but found \`mixed\`.␊
212-
2 | @bar export class Foo {}␊
213213
214214
Output:␊
215215
1 | @foo␊
@@ -380,7 +380,7 @@ Generated by [AVA](https://avajs.dev).
380380
`␊
381381
Message:␊
382382
> 1 | @foo export @bar class Foo {}␊
383-
| ^^^^ Expected export decorators to be positioned \`before\`, but found \`mixed\`.␊
383+
| ^^^^ Expected export decorators to be positioned \`before\`, but found \`mixed\`.␊
384384
385385
Output:␊
386386
1 | @foo @bar export class Foo {}␊
@@ -403,7 +403,7 @@ Generated by [AVA](https://avajs.dev).
403403
`␊
404404
Message:␊
405405
> 1 | @foo export default @bar class Foo {}␊
406-
| ^^^^ Expected export decorators to be positioned \`before\`, but found \`mixed\`.␊
406+
| ^^^^ Expected export decorators to be positioned \`before\`, but found \`mixed\`.␊
407407
408408
Output:␊
409409
1 | @foo @bar export default class Foo {}␊
4 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)