Skip to content

Commit e2f8b70

Browse files
waynzhFloEdelmann
andauthored
feat(define-macros-order): add defineExposeLast option (#2349)
Co-authored-by: Flo Edelmann <git@flo-edelmann.de>
1 parent a89dd10 commit e2f8b70

File tree

5 files changed

+276
-2
lines changed

5 files changed

+276
-2
lines changed

docs/rules/define-macros-order.md

+33-1
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ This rule reports the `defineProps` and `defineEmits` compiler macros when they
2020
```json
2121
{
2222
"vue/define-macros-order": ["error", {
23-
"order": ["defineProps", "defineEmits"]
23+
"order": ["defineProps", "defineEmits"],
24+
"defineExposeLast": false
2425
}]
2526
}
2627
```
2728

2829
- `order` (`string[]`) ... The order of defineEmits and defineProps macros. You can also add `"defineOptions"` and `"defineSlots"`.
30+
- `defineExposeLast` (`boolean`) ... Force `defineExpose` at the end.
2931

3032
### `{ "order": ["defineProps", "defineEmits"] }` (default)
3133

@@ -111,6 +113,36 @@ const slots = defineSlots()
111113

112114
</eslint-code-block>
113115

116+
### `{ "defineExposeLast": true }`
117+
118+
<eslint-code-block fix :rules="{'vue/define-macros-order': ['error', {defineExposeLast: true}]}">
119+
120+
```vue
121+
<!-- ✓ GOOD -->
122+
<script setup>
123+
defineProps(/* ... */)
124+
defineEmits(/* ... */)
125+
const slots = defineSlots()
126+
defineExpose({/* ... */})
127+
</script>
128+
```
129+
130+
</eslint-code-block>
131+
132+
<eslint-code-block fix :rules="{'vue/define-macros-order': ['error', {defineExposeLast: true}]}">
133+
134+
```vue
135+
<!-- ✗ BAD -->
136+
<script setup>
137+
defineProps(/* ... */)
138+
defineEmits(/* ... */)
139+
defineExpose({/* ... */})
140+
const slots = defineSlots()
141+
</script>
142+
```
143+
144+
</eslint-code-block>
145+
114146
## :rocket: Version
115147

116148
This rule was introduced in eslint-plugin-vue v8.7.0

lib/rules/define-macros-order.js

+76-1
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,12 @@ function create(context) {
9595
const options = context.options
9696
/** @type {[string, string]} */
9797
const order = (options[0] && options[0].order) || DEFAULT_ORDER
98+
/** @type {boolean} */
99+
const defineExposeLast = (options[0] && options[0].defineExposeLast) || false
98100
/** @type {Map<string, ASTNode>} */
99101
const macrosNodes = new Map()
102+
/** @type {ASTNode} */
103+
let defineExposeNode
100104

101105
return utils.compositingVisitors(
102106
utils.defineScriptSetupVisitor(context, {
@@ -111,6 +115,9 @@ function create(context) {
111115
},
112116
onDefineSlotsExit(node) {
113117
macrosNodes.set(MACROS_SLOTS, getDefineMacrosStatement(node))
118+
},
119+
onDefineExposeExit(node) {
120+
defineExposeNode = getDefineMacrosStatement(node)
114121
}
115122
}),
116123
{
@@ -131,6 +138,14 @@ function create(context) {
131138
(data) => utils.isDef(data.node)
132139
)
133140

141+
// check last node
142+
if (defineExposeLast) {
143+
const lastNode = program.body[program.body.length - 1]
144+
if (defineExposeNode && lastNode !== defineExposeNode) {
145+
reportExposeNotOnBottom(defineExposeNode, lastNode)
146+
}
147+
}
148+
134149
for (const [index, should] of orderedList.entries()) {
135150
const targetStatement = program.body[firstStatementIndex + index]
136151

@@ -172,6 +187,58 @@ function create(context) {
172187
})
173188
}
174189

190+
/**
191+
* @param {ASTNode} node
192+
* @param {ASTNode} lastNode
193+
*/
194+
function reportExposeNotOnBottom(node, lastNode) {
195+
context.report({
196+
node,
197+
loc: node.loc,
198+
messageId: 'defineExposeNotTheLast',
199+
suggest: [
200+
{
201+
messageId: 'putExposeAtTheLast',
202+
fix(fixer) {
203+
return moveNodeToLast(fixer, node, lastNode)
204+
}
205+
}
206+
]
207+
})
208+
}
209+
210+
/**
211+
* Move all lines of "node" with its comments to after the "target"
212+
* @param {RuleFixer} fixer
213+
* @param {ASTNode} node
214+
* @param {ASTNode} target
215+
*/
216+
function moveNodeToLast(fixer, node, target) {
217+
// get comments under tokens(if any)
218+
const beforeNodeToken = sourceCode.getTokenBefore(node)
219+
const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
220+
includeComments: true
221+
})
222+
const nextNodeComment = sourceCode.getTokenAfter(node, {
223+
includeComments: true
224+
})
225+
226+
// remove position: node (and comments) to next node (and comments)
227+
const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
228+
const cutEnd = getLineStartIndex(nextNodeComment, node)
229+
230+
// insert text: comment + node
231+
const textNode = sourceCode.getText(
232+
node,
233+
node.range[0] - beforeNodeToken.range[1]
234+
)
235+
236+
return [
237+
fixer.insertTextAfter(target, textNode),
238+
fixer.removeRange([cutStart, cutEnd])
239+
]
240+
}
241+
175242
/**
176243
* Move all lines of "node" with its comments to before the "target"
177244
* @param {RuleFixer} fixer
@@ -255,6 +322,7 @@ module.exports = {
255322
url: 'https://eslint.vuejs.org/rules/define-macros-order.html'
256323
},
257324
fixable: 'code',
325+
hasSuggestions: true,
258326
schema: [
259327
{
260328
type: 'object',
@@ -266,14 +334,21 @@ module.exports = {
266334
},
267335
uniqueItems: true,
268336
additionalItems: false
337+
},
338+
defineExposeLast: {
339+
type: 'boolean'
269340
}
270341
},
271342
additionalProperties: false
272343
}
273344
],
274345
messages: {
275346
macrosNotOnTop:
276-
'{{macro}} should be the first statement in `<script setup>` (after any potential import statements or type definitions).'
347+
'{{macro}} should be the first statement in `<script setup>` (after any potential import statements or type definitions).',
348+
defineExposeNotTheLast:
349+
'`defineExpose` should be the last statement in `<script setup>`.',
350+
putExposeAtTheLast:
351+
'Put `defineExpose` as the last statement in `<script setup>`.'
277352
}
278353
},
279354
create

lib/utils/index.js

+9
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,8 @@ module.exports = {
13141314
* - `onDefineOptionsExit` ... Event when defineOptions visit ends.
13151315
* - `onDefineSlotsEnter` ... Event when defineSlots is found.
13161316
* - `onDefineSlotsExit` ... Event when defineSlots visit ends.
1317+
* - `onDefineExposeEnter` ... Event when defineExpose is found.
1318+
* - `onDefineExposeExit` ... Event when defineExpose visit ends.
13171319
*
13181320
* @param {RuleContext} context The ESLint rule context object.
13191321
* @param {ScriptSetupVisitor} visitor The visitor to traverse the AST nodes.
@@ -1401,6 +1403,13 @@ module.exports = {
14011403
'onDefineSlotsExit',
14021404
(candidateMacro, node) => candidateMacro === node,
14031405
() => undefined
1406+
),
1407+
new MacroListener(
1408+
'defineExpose',
1409+
'onDefineExposeEnter',
1410+
'onDefineExposeExit',
1411+
(candidateMacro, node) => candidateMacro === node,
1412+
() => undefined
14041413
)
14051414
].filter((m) => m.hasListener)
14061415
if (macroListenerList.length > 0) {

tests/lib/rules/define-macros-order.js

+156
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,22 @@ const optionsPropsFirst = [
2727
}
2828
]
2929

30+
const optionsExposeLast = [
31+
{
32+
defineExposeLast: true
33+
}
34+
]
35+
3036
function message(macro) {
3137
return `${macro} should be the first statement in \`<script setup>\` (after any potential import statements or type definitions).`
3238
}
3339

40+
const defineExposeNotTheLast =
41+
'`defineExpose` should be the last statement in `<script setup>`.'
42+
43+
const putExposeAtBottom =
44+
'Put `defineExpose` as the last statement in `<script setup>`.'
45+
3446
tester.run('define-macros-order', rule, {
3547
valid: [
3648
{
@@ -170,6 +182,48 @@ tester.run('define-macros-order', rule, {
170182
order: ['defineOptions', 'defineEmits', 'defineProps', 'defineSlots']
171183
}
172184
]
185+
},
186+
{
187+
filename: 'test.vue',
188+
code: `
189+
<script setup>
190+
import Foo from 'foo'
191+
/** props */
192+
defineProps(['foo'])
193+
/** options */
194+
defineOptions({})
195+
/** expose */
196+
defineExpose({})
197+
</script>
198+
`,
199+
options: optionsExposeLast
200+
},
201+
{
202+
filename: 'test.vue',
203+
code: `
204+
<script setup lang="ts">
205+
import Foo from 'foo'
206+
/** props */
207+
const props = defineProps({
208+
test: Boolean
209+
})
210+
/** emits */
211+
defineEmits(['update:foo'])
212+
/** slots */
213+
const slots = defineSlots()
214+
/** expose */
215+
defineExpose({})
216+
</script>
217+
`,
218+
options: [
219+
{
220+
order: ['defineProps', 'defineEmits'],
221+
defineExposeLast: true
222+
}
223+
],
224+
parserOptions: {
225+
parser: require.resolve('@typescript-eslint/parser')
226+
}
173227
}
174228
],
175229
invalid: [
@@ -622,6 +676,108 @@ tester.run('define-macros-order', rule, {
622676
line: 6
623677
}
624678
]
679+
},
680+
{
681+
filename: 'test.vue',
682+
code: `
683+
<script setup>
684+
/** emits */
685+
defineEmits(['update:foo'])
686+
/** expose */
687+
defineExpose({})
688+
/** slots */
689+
const slots = defineSlots()
690+
</script>
691+
`,
692+
output: null,
693+
options: optionsExposeLast,
694+
errors: [
695+
{
696+
message: defineExposeNotTheLast,
697+
line: 6,
698+
suggestions: [
699+
{
700+
desc: putExposeAtBottom,
701+
output: `
702+
<script setup>
703+
/** emits */
704+
defineEmits(['update:foo'])
705+
/** slots */
706+
const slots = defineSlots()
707+
/** expose */
708+
defineExpose({})
709+
</script>
710+
`
711+
}
712+
]
713+
}
714+
]
715+
},
716+
{
717+
filename: 'test.vue',
718+
code: `
719+
<script setup>
720+
/** emits */
721+
defineEmits(['update:foo'])
722+
/** expose */
723+
defineExpose({})
724+
/** options */
725+
defineOptions({})
726+
/** props */
727+
const props = defineProps(['foo'])
728+
/** slots */
729+
const slots = defineSlots()
730+
</script>
731+
`,
732+
output: `
733+
<script setup>
734+
/** options */
735+
defineOptions({})
736+
/** emits */
737+
defineEmits(['update:foo'])
738+
/** expose */
739+
defineExpose({})
740+
/** props */
741+
const props = defineProps(['foo'])
742+
/** slots */
743+
const slots = defineSlots()
744+
</script>
745+
`,
746+
options: [
747+
{
748+
order: ['defineOptions', 'defineEmits', 'defineProps'],
749+
defineExposeLast: true
750+
}
751+
],
752+
errors: [
753+
{
754+
message: defineExposeNotTheLast,
755+
line: 6,
756+
suggestions: [
757+
{
758+
desc: putExposeAtBottom,
759+
output: `
760+
<script setup>
761+
/** emits */
762+
defineEmits(['update:foo'])
763+
/** options */
764+
defineOptions({})
765+
/** props */
766+
const props = defineProps(['foo'])
767+
/** slots */
768+
const slots = defineSlots()
769+
/** expose */
770+
defineExpose({})
771+
</script>
772+
`
773+
}
774+
]
775+
},
776+
{
777+
message: message('defineOptions'),
778+
line: 8
779+
}
780+
]
625781
}
626782
]
627783
})

typings/eslint-plugin-vue/util-types/utils.ts

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase {
4444
onDefineOptionsExit?(node: CallExpression): void
4545
onDefineSlotsEnter?(node: CallExpression): void
4646
onDefineSlotsExit?(node: CallExpression): void
47+
onDefineExposeEnter?(node: CallExpression): void
48+
onDefineExposeExit?(node: CallExpression): void
4749
[query: string]:
4850
| ((node: VAST.ParamNode) => void)
4951
| ((node: CallExpression, props: ComponentProp[]) => void)

0 commit comments

Comments
 (0)