@shrpne/eslint-plugin-vue-extra 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/index.js +34 -0
- package/package.json +47 -0
- package/rules/no-boolean-default-false.js +259 -0
- package/rules/no-empty-defaults.js +48 -0
- package/rules/no-falsy-default-with-optional-prop.js +376 -0
- package/rules/prefer-optional-boolean-prop.js +258 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @shrpne/eslint-plugin-vue-extra
|
|
2
|
+
|
|
3
|
+
ESLint rules for Vue + TypeScript that remove redundant/noise patterns around `defineProps` + `withDefaults`, where core/recommended Vue rules are mostly targeted non TypeScript-specific cases.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @shrpne/eslint-plugin-vue-extra @vue/eslint-config-typescript eslint-plugin-vue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start (recommended preset)
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
// eslint.config.mjs
|
|
15
|
+
import pluginVue from 'eslint-plugin-vue';
|
|
16
|
+
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
|
|
17
|
+
import vueExtra from '@shrpne/eslint-plugin-vue-extra';
|
|
18
|
+
|
|
19
|
+
export default defineConfigWithVueTs(
|
|
20
|
+
pluginVue.configs['flat/essential'],
|
|
21
|
+
vueTsConfigs.recommendedTypeChecked,
|
|
22
|
+
vueExtra.configs.recommended,
|
|
23
|
+
);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The preset enables all rules from this plugin and disables `vue/require-default-prop` (it conflicts with `no-falsy-default-with-optional-prop`).
|
|
27
|
+
|
|
28
|
+
## Rules
|
|
29
|
+
|
|
30
|
+
- `no-falsy-default-with-optional-prop`: disallow configured falsy defaults for optional props.
|
|
31
|
+
- `no-boolean-default-false`: disallow `false` defaults for boolean props.
|
|
32
|
+
- `prefer-optional-boolean-prop`: prefer optional boolean props unless default is `true`.
|
|
33
|
+
- `no-empty-defaults`: remove redundant `withDefaults(..., {})`.
|
|
34
|
+
|
|
35
|
+
## Rule details
|
|
36
|
+
|
|
37
|
+
### `no-falsy-default-with-optional-prop`
|
|
38
|
+
|
|
39
|
+
In TypeScript, optional props stay typed with `| undefined`; defaults like `undefined` and `false` are usually noise.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
['error', { disallow: ['undefined', 'false', 'null', `''`, '0'] }]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Default:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
['error', { disallow: ['undefined', 'false'] }]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// before
|
|
55
|
+
withDefaults(defineProps<{
|
|
56
|
+
label?: string;
|
|
57
|
+
isVisible?: boolean;
|
|
58
|
+
}>(), {
|
|
59
|
+
label: undefined,
|
|
60
|
+
isVisible: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// after
|
|
64
|
+
withDefaults(defineProps<{
|
|
65
|
+
label?: string;
|
|
66
|
+
isVisible?: boolean;
|
|
67
|
+
}>(), {});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `prefer-optional-boolean-prop`
|
|
71
|
+
|
|
72
|
+
Required booleans without `default: true` are awkward in templates; optional booleans model presence/absence better.
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
['error', { fixReferencedTypes: true }]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
By default, fixer only updates inline `defineProps<{ ... }>()`.
|
|
81
|
+
With `fixReferencedTypes: true`, it can also fix same-file referenced types (`defineProps<Props>()`).
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// before
|
|
85
|
+
withDefaults(defineProps<{ enabled: boolean }>(), {});
|
|
86
|
+
|
|
87
|
+
// after
|
|
88
|
+
withDefaults(defineProps<{ enabled?: boolean }>(), {});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `no-boolean-default-false`
|
|
92
|
+
|
|
93
|
+
This rule focuses on reducing noise by removing redundant `false` defaults. It's related to `vue/no-boolean-default`, but serves a different goal, `vue/no-boolean-default` focuses on HTML-compatibility disallowing `default: true` behavior, while this rule does nothing for `default: true` cases. So `"vue/no-boolean-default": ["error", "default-false"]` acts opposite to this rule.
|
|
94
|
+
|
|
95
|
+
When not to use:
|
|
96
|
+
|
|
97
|
+
Generally `vue/no-boolean-default: "error"` is recommended to remove all boolean defaults. But if you want to keep `default: true` cases, this rule will help remove `false` defaults.
|
|
98
|
+
|
|
99
|
+
Also, if you use `vueExtra.configs.recommended` preset, this case will be already covered:
|
|
100
|
+
- `prefer-optional-boolean-prop` - makes boolean prop optional
|
|
101
|
+
- `no-falsy-default-with-optional-prop` - removes falsy defaults for this optional prop
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Options: none.
|
|
105
|
+
|
|
106
|
+
Fixer: removes the `false` entry from `withDefaults(...)`.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// before
|
|
110
|
+
withDefaults(defineProps<{
|
|
111
|
+
enabled?: boolean;
|
|
112
|
+
showLabel?: boolean;
|
|
113
|
+
}>(), {
|
|
114
|
+
enabled: false,
|
|
115
|
+
showLabel: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// after
|
|
119
|
+
withDefaults(defineProps<{
|
|
120
|
+
enabled?: boolean;
|
|
121
|
+
showLabel?: boolean;
|
|
122
|
+
}>(), {
|
|
123
|
+
showLabel: true,
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `no-empty-defaults`
|
|
128
|
+
|
|
129
|
+
`withDefaults(defineProps(...), {})` is redundant.
|
|
130
|
+
|
|
131
|
+
Options: none.
|
|
132
|
+
|
|
133
|
+
Fixer: rewrites to `defineProps(...)`.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// before
|
|
137
|
+
withDefaults(
|
|
138
|
+
defineProps<{
|
|
139
|
+
foo?: string
|
|
140
|
+
}>(),
|
|
141
|
+
{}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// after
|
|
145
|
+
defineProps<{
|
|
146
|
+
foo?: string
|
|
147
|
+
}>();
|
|
148
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import noFalsyDefaultWithOptionalProp from './rules/no-falsy-default-with-optional-prop.js';
|
|
2
|
+
import noBooleanDefaultFalse from './rules/no-boolean-default-false.js';
|
|
3
|
+
import noEmptyWithDefaults from './rules/no-empty-defaults.js';
|
|
4
|
+
import preferOptionalBooleanProp from './rules/prefer-optional-boolean-prop.js';
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
meta: {
|
|
8
|
+
name: '@shrpne/eslint-plugin-vue-extra',
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
},
|
|
11
|
+
rules: {
|
|
12
|
+
'no-falsy-default-with-optional-prop': noFalsyDefaultWithOptionalProp,
|
|
13
|
+
'no-boolean-default-false': noBooleanDefaultFalse,
|
|
14
|
+
'no-empty-defaults': noEmptyWithDefaults,
|
|
15
|
+
'prefer-optional-boolean-prop': preferOptionalBooleanProp,
|
|
16
|
+
},
|
|
17
|
+
configs: {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
Object.assign(plugin.configs, {
|
|
21
|
+
recommended: {
|
|
22
|
+
plugins: {
|
|
23
|
+
'vue-extra': plugin,
|
|
24
|
+
},
|
|
25
|
+
rules: {
|
|
26
|
+
'vue/require-default-prop': 'off',
|
|
27
|
+
'vue-extra/no-falsy-default-with-optional-prop': 'error',
|
|
28
|
+
'vue-extra/prefer-optional-boolean-prop': ['error', { fixReferencedTypes: true }],
|
|
29
|
+
'vue-extra/no-empty-defaults': 'error',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shrpne/eslint-plugin-vue-extra",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Extra ESLint rules for Vue projects",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"eslint",
|
|
8
|
+
"eslintplugin",
|
|
9
|
+
"vue",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/shrpne/eslint-plugin-vue-extra.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/shrpne/eslint-plugin-vue-extra#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/shrpne/eslint-plugin-vue-extra/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"rules",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"publish": "npm publish --access public",
|
|
35
|
+
"test": "node --test"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
39
|
+
"eslint": "^9.0.0",
|
|
40
|
+
"typescript": "^5.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"eslint": "^9.0.0",
|
|
44
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
45
|
+
"typescript": "^5.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
function getPropNameFromKey(key) {
|
|
4
|
+
if (!key) return null;
|
|
5
|
+
if (key.type === 'Identifier') return key.name;
|
|
6
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isWithDefaultsCall(node) {
|
|
11
|
+
return (
|
|
12
|
+
node &&
|
|
13
|
+
node.type === 'CallExpression' &&
|
|
14
|
+
node.callee &&
|
|
15
|
+
node.callee.type === 'Identifier' &&
|
|
16
|
+
node.callee.name === 'withDefaults'
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isDefinePropsCall(node) {
|
|
21
|
+
return (
|
|
22
|
+
node &&
|
|
23
|
+
node.type === 'CallExpression' &&
|
|
24
|
+
node.callee &&
|
|
25
|
+
node.callee.type === 'Identifier' &&
|
|
26
|
+
node.callee.name === 'defineProps'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFalseLiteral(node) {
|
|
31
|
+
return node && node.type === 'Literal' && node.value === false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildRemovePropertyFix(sourceCode, prop) {
|
|
35
|
+
const parent = prop.parent;
|
|
36
|
+
if (!parent || parent.type !== 'ObjectExpression') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const index = parent.properties.indexOf(prop);
|
|
41
|
+
if (index === -1) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tokenAfterProp = sourceCode.getTokenAfter(prop);
|
|
46
|
+
const tokenBeforeProp = sourceCode.getTokenBefore(prop);
|
|
47
|
+
|
|
48
|
+
const hasNext = index < parent.properties.length - 1;
|
|
49
|
+
if (hasNext) {
|
|
50
|
+
if (!tokenAfterProp || tokenAfterProp.value !== ',') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (fixer) => fixer.removeRange([prop.range[0], tokenAfterProp.range[1]]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hasPrev = index > 0;
|
|
58
|
+
if (hasPrev) {
|
|
59
|
+
if (!tokenBeforeProp || tokenBeforeProp.value !== ',') {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (fixer) => fixer.removeRange([tokenBeforeProp.range[0], prop.range[1]]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parent.properties.length === 1) {
|
|
67
|
+
return (fixer) => fixer.replaceText(parent, '{}');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (tokenAfterProp && tokenAfterProp.value === ',') {
|
|
71
|
+
return (fixer) => fixer.removeRange([prop.range[0], tokenAfterProp.range[1]]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (fixer) => fixer.remove(prop);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getLineIndent(text, index) {
|
|
78
|
+
let lineStart = index;
|
|
79
|
+
while (lineStart > 0 && text[lineStart - 1] !== '\n' && text[lineStart - 1] !== '\r') {
|
|
80
|
+
lineStart -= 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let cursor = lineStart;
|
|
84
|
+
while (cursor < text.length && (text[cursor] === ' ' || text[cursor] === '\t')) {
|
|
85
|
+
cursor += 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return text.slice(lineStart, cursor);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildReplaceObjectWithoutPropertiesFix(sourceCode, objectNode, propsToRemove) {
|
|
92
|
+
if (!objectNode || objectNode.type !== 'ObjectExpression') return null;
|
|
93
|
+
if (!propsToRemove || propsToRemove.size === 0) return null;
|
|
94
|
+
|
|
95
|
+
const remaining = objectNode.properties.filter((prop) => !propsToRemove.has(prop));
|
|
96
|
+
if (remaining.length === objectNode.properties.length) return null;
|
|
97
|
+
|
|
98
|
+
if (remaining.length === 0) {
|
|
99
|
+
return (fixer) => fixer.replaceText(objectNode, '{}');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const objectText = sourceCode.getText(objectNode);
|
|
103
|
+
const isMultiline = objectText.includes('\n') || objectText.includes('\r');
|
|
104
|
+
|
|
105
|
+
if (!isMultiline) {
|
|
106
|
+
const content = remaining.map((prop) => sourceCode.getText(prop)).join(', ');
|
|
107
|
+
return (fixer) => fixer.replaceText(objectNode, `{ ${content} }`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fullText = sourceCode.text;
|
|
111
|
+
const objectIndent = getLineIndent(fullText, objectNode.range[0]);
|
|
112
|
+
const propertyIndent = getLineIndent(fullText, remaining[0].range[0]) || `${objectIndent} `;
|
|
113
|
+
const lines = remaining.map((prop) => `${propertyIndent}${sourceCode.getText(prop)},`);
|
|
114
|
+
const replacement = `{\n${lines.join('\n')}\n${objectIndent}}`;
|
|
115
|
+
|
|
116
|
+
return (fixer) => fixer.replaceText(objectNode, replacement);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isBooleanLikeType(type) {
|
|
120
|
+
if (!type) return false;
|
|
121
|
+
|
|
122
|
+
if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return true;
|
|
123
|
+
|
|
124
|
+
if ((type.flags & ts.TypeFlags.Union) !== 0 && type.types) {
|
|
125
|
+
return type.types.some((part) => (part.flags & ts.TypeFlags.BooleanLike) !== 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getOptionalFlag(symbol) {
|
|
132
|
+
if ((symbol.getFlags() & ts.SymbolFlags.Optional) !== 0) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const declarations = symbol.getDeclarations() || [];
|
|
137
|
+
for (const declaration of declarations) {
|
|
138
|
+
if (
|
|
139
|
+
ts.isPropertySignature(declaration) ||
|
|
140
|
+
ts.isPropertyDeclaration(declaration) ||
|
|
141
|
+
ts.isParameter(declaration)
|
|
142
|
+
) {
|
|
143
|
+
if (declaration.questionToken) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getPropMetadataFromDefineProps(context, definePropsNode) {
|
|
153
|
+
const services = context.sourceCode.parserServices;
|
|
154
|
+
if (!services || !services.program || !services.esTreeNodeToTSNodeMap) return null;
|
|
155
|
+
|
|
156
|
+
const checker = services.program.getTypeChecker();
|
|
157
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(definePropsNode);
|
|
158
|
+
if (!tsNode) return null;
|
|
159
|
+
|
|
160
|
+
const typeArg = tsNode.typeArguments && tsNode.typeArguments[0];
|
|
161
|
+
if (!typeArg) return null;
|
|
162
|
+
|
|
163
|
+
const type = checker.getTypeFromTypeNode(typeArg);
|
|
164
|
+
if (!type) return null;
|
|
165
|
+
|
|
166
|
+
const map = new Map();
|
|
167
|
+
for (const symbol of type.getProperties()) {
|
|
168
|
+
const name = symbol.getName();
|
|
169
|
+
const propType = checker.getTypeOfSymbolAtLocation(symbol, typeArg);
|
|
170
|
+
|
|
171
|
+
map.set(name, {
|
|
172
|
+
isBooleanLike: isBooleanLikeType(propType),
|
|
173
|
+
isOptional: getOptionalFlag(symbol),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return map;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const rule = {
|
|
181
|
+
meta: {
|
|
182
|
+
type: 'suggestion',
|
|
183
|
+
fixable: 'code',
|
|
184
|
+
docs: {
|
|
185
|
+
description:
|
|
186
|
+
'Disallow `false` defaults for boolean props in withDefaults(defineProps<...>(), { ... })',
|
|
187
|
+
},
|
|
188
|
+
schema: [],
|
|
189
|
+
messages: {
|
|
190
|
+
noFalseDefault:
|
|
191
|
+
'Boolean prop "{{name}}" should not default to `false`; this default is redundant.',
|
|
192
|
+
requiresTypeInfo:
|
|
193
|
+
'@shrpne/vue-extra/no-boolean-default-false requires type information. Configure @typescript-eslint/parser with parserOptions.project.',
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
create(context) {
|
|
198
|
+
const services = context.sourceCode.parserServices;
|
|
199
|
+
const hasTypeInfo = !!(services && services.program && services.esTreeNodeToTSNodeMap);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
CallExpression(node) {
|
|
203
|
+
if (!isWithDefaultsCall(node)) return;
|
|
204
|
+
if (node.arguments.length < 2) return;
|
|
205
|
+
|
|
206
|
+
const [firstArg, secondArg] = node.arguments;
|
|
207
|
+
if (!isDefinePropsCall(firstArg)) return;
|
|
208
|
+
if (!secondArg || secondArg.type !== 'ObjectExpression') return;
|
|
209
|
+
|
|
210
|
+
if (!hasTypeInfo) {
|
|
211
|
+
context.report({ node, messageId: 'requiresTypeInfo' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const propMetadataMap = getPropMetadataFromDefineProps(context, firstArg);
|
|
216
|
+
if (!propMetadataMap) return;
|
|
217
|
+
|
|
218
|
+
const violations = [];
|
|
219
|
+
for (const prop of secondArg.properties) {
|
|
220
|
+
if (prop.type !== 'Property') continue;
|
|
221
|
+
if (prop.computed) continue;
|
|
222
|
+
if (prop.kind !== 'init') continue;
|
|
223
|
+
if (!isFalseLiteral(prop.value)) continue;
|
|
224
|
+
|
|
225
|
+
const name = getPropNameFromKey(prop.key);
|
|
226
|
+
if (!name) continue;
|
|
227
|
+
|
|
228
|
+
const metadata = propMetadataMap.get(name);
|
|
229
|
+
if (!metadata || !metadata.isBooleanLike) continue;
|
|
230
|
+
|
|
231
|
+
violations.push({ prop, name });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (violations.length === 0) return;
|
|
235
|
+
|
|
236
|
+
const allPropsToRemove = new Set(violations.map((violation) => violation.prop));
|
|
237
|
+
const sharedFix = buildReplaceObjectWithoutPropertiesFix(
|
|
238
|
+
context.sourceCode,
|
|
239
|
+
secondArg,
|
|
240
|
+
allPropsToRemove
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
for (const violation of violations) {
|
|
244
|
+
const removeFix = violations.length > 1
|
|
245
|
+
? sharedFix
|
|
246
|
+
: buildRemovePropertyFix(context.sourceCode, violation.prop);
|
|
247
|
+
context.report({
|
|
248
|
+
node: violation.prop.value,
|
|
249
|
+
messageId: 'noFalseDefault',
|
|
250
|
+
data: { name: violation.name },
|
|
251
|
+
fix: removeFix || null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export default rule;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function isWithDefaultsCall(node) {
|
|
2
|
+
return (
|
|
3
|
+
node &&
|
|
4
|
+
node.type === 'CallExpression' &&
|
|
5
|
+
node.callee &&
|
|
6
|
+
node.callee.type === 'Identifier' &&
|
|
7
|
+
node.callee.name === 'withDefaults'
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isEmptyObjectExpression(node) {
|
|
12
|
+
return node && node.type === 'ObjectExpression' && node.properties.length === 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const noEmptyWithDefaults = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
fixable: 'code',
|
|
19
|
+
docs: {
|
|
20
|
+
description: 'Disallow empty withDefaults(...) wrappers',
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: {
|
|
24
|
+
noEmptyWithDefaults:
|
|
25
|
+
'`withDefaults` with an empty defaults object is redundant. Use `defineProps(...)` directly.',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
CallExpression(node) {
|
|
32
|
+
if (!isWithDefaultsCall(node)) return;
|
|
33
|
+
if (node.arguments.length < 2) return;
|
|
34
|
+
|
|
35
|
+
const [firstArg, secondArg] = node.arguments;
|
|
36
|
+
if (!isEmptyObjectExpression(secondArg)) return;
|
|
37
|
+
|
|
38
|
+
context.report({
|
|
39
|
+
node,
|
|
40
|
+
messageId: 'noEmptyWithDefaults',
|
|
41
|
+
fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(firstArg)),
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default noEmptyWithDefaults;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default disallowed values.
|
|
5
|
+
* Note: user asked for undefined and false; null is included by default because it’s also commonly “falsy default”.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_DISALLOW = ['undefined', 'false'];
|
|
8
|
+
|
|
9
|
+
function getPropNameFromKey(key) {
|
|
10
|
+
if (!key) return null;
|
|
11
|
+
if (key.type === 'Identifier') return key.name;
|
|
12
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isDisallowedValue(node, disallowSet) {
|
|
17
|
+
// false / null / 0 / "" are Literals in ESTree (with @typescript-eslint/parser)
|
|
18
|
+
if (node.type === 'Literal') {
|
|
19
|
+
if (node.value === false && disallowSet.has('false')) return true;
|
|
20
|
+
if (node.value === null && disallowSet.has('null')) return true;
|
|
21
|
+
if (node.value === 0 && disallowSet.has('0')) return true;
|
|
22
|
+
if (node.value === '' && disallowSet.has("''")) return true;
|
|
23
|
+
// NOTE: `undefined` is not a Literal in ESTree
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `undefined`
|
|
28
|
+
if (node.type === 'Identifier' && node.name === 'undefined' && disallowSet.has('undefined')) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `void 0`
|
|
33
|
+
if (
|
|
34
|
+
node.type === 'UnaryExpression' &&
|
|
35
|
+
node.operator === 'void' &&
|
|
36
|
+
disallowSet.has('undefined')
|
|
37
|
+
) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// empty template literal: `` (rare but possible)
|
|
42
|
+
if (
|
|
43
|
+
node.type === 'TemplateLiteral' &&
|
|
44
|
+
node.expressions.length === 0 &&
|
|
45
|
+
node.quasis.length === 1 &&
|
|
46
|
+
node.quasis[0].value.raw === '' &&
|
|
47
|
+
disallowSet.has("''")
|
|
48
|
+
) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isDefinePropsCall(node) {
|
|
56
|
+
return (
|
|
57
|
+
node &&
|
|
58
|
+
node.type === 'CallExpression' &&
|
|
59
|
+
node.callee &&
|
|
60
|
+
node.callee.type === 'Identifier' &&
|
|
61
|
+
node.callee.name === 'defineProps'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isWithDefaultsCall(node) {
|
|
66
|
+
return (
|
|
67
|
+
node &&
|
|
68
|
+
node.type === 'CallExpression' &&
|
|
69
|
+
node.callee &&
|
|
70
|
+
node.callee.type === 'Identifier' &&
|
|
71
|
+
node.callee.name === 'withDefaults'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function expandRemovalToWholeLine(sourceCode, start, end) {
|
|
76
|
+
const text = sourceCode.text;
|
|
77
|
+
|
|
78
|
+
let lineStart = start;
|
|
79
|
+
while (lineStart > 0 && text[lineStart - 1] !== '\n' && text[lineStart - 1] !== '\r') {
|
|
80
|
+
lineStart -= 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const prefix = text.slice(lineStart, start);
|
|
84
|
+
if (!/^[ \t]*$/.test(prefix)) {
|
|
85
|
+
return [start, end];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let lineEnd = end;
|
|
89
|
+
while (lineEnd < text.length && text[lineEnd] !== '\n' && text[lineEnd] !== '\r') {
|
|
90
|
+
lineEnd += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const suffix = text.slice(end, lineEnd);
|
|
94
|
+
if (!/^[ \t]*$/.test(suffix)) {
|
|
95
|
+
return [start, end];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (lineEnd < text.length) {
|
|
99
|
+
if (text[lineEnd] === '\r' && text[lineEnd + 1] === '\n') {
|
|
100
|
+
lineEnd += 2;
|
|
101
|
+
} else {
|
|
102
|
+
lineEnd += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return [lineStart, lineEnd];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildRemovePropertyFix(sourceCode, prop) {
|
|
110
|
+
const parent = prop.parent;
|
|
111
|
+
if (!parent || parent.type !== 'ObjectExpression') {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const index = parent.properties.indexOf(prop);
|
|
116
|
+
if (index === -1) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tokenAfterProp = sourceCode.getTokenAfter(prop);
|
|
121
|
+
const tokenBeforeProp = sourceCode.getTokenBefore(prop);
|
|
122
|
+
|
|
123
|
+
const hasNext = index < parent.properties.length - 1;
|
|
124
|
+
if (hasNext) {
|
|
125
|
+
if (!tokenAfterProp || tokenAfterProp.value !== ',') {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (fixer) => {
|
|
130
|
+
const [start, end] = expandRemovalToWholeLine(
|
|
131
|
+
sourceCode,
|
|
132
|
+
prop.range[0],
|
|
133
|
+
tokenAfterProp.range[1]
|
|
134
|
+
);
|
|
135
|
+
return fixer.removeRange([start, end]);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const hasPrev = index > 0;
|
|
140
|
+
if (hasPrev) {
|
|
141
|
+
if (!tokenBeforeProp || tokenBeforeProp.value !== ',') {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (fixer) => fixer.removeRange([tokenBeforeProp.range[0], prop.range[1]]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (parent.properties.length === 1) {
|
|
149
|
+
return (fixer) => fixer.replaceText(parent, '{}');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (tokenAfterProp && tokenAfterProp.value === ',') {
|
|
153
|
+
return (fixer) => {
|
|
154
|
+
const [start, end] = expandRemovalToWholeLine(
|
|
155
|
+
sourceCode,
|
|
156
|
+
prop.range[0],
|
|
157
|
+
tokenAfterProp.range[1]
|
|
158
|
+
);
|
|
159
|
+
return fixer.removeRange([start, end]);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (fixer) => fixer.remove(prop);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getLineIndent(text, index) {
|
|
167
|
+
let lineStart = index;
|
|
168
|
+
while (lineStart > 0 && text[lineStart - 1] !== '\n' && text[lineStart - 1] !== '\r') {
|
|
169
|
+
lineStart -= 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let cursor = lineStart;
|
|
173
|
+
while (cursor < text.length && (text[cursor] === ' ' || text[cursor] === '\t')) {
|
|
174
|
+
cursor += 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return text.slice(lineStart, cursor);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildReplaceObjectWithoutPropertiesFix(sourceCode, objectNode, propsToRemove) {
|
|
181
|
+
if (!objectNode || objectNode.type !== 'ObjectExpression') return null;
|
|
182
|
+
if (!propsToRemove || propsToRemove.size === 0) return null;
|
|
183
|
+
|
|
184
|
+
const remaining = objectNode.properties.filter((prop) => !propsToRemove.has(prop));
|
|
185
|
+
if (remaining.length === objectNode.properties.length) return null;
|
|
186
|
+
|
|
187
|
+
if (remaining.length === 0) {
|
|
188
|
+
return (fixer) => fixer.replaceText(objectNode, '{}');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const objectText = sourceCode.getText(objectNode);
|
|
192
|
+
const isMultiline = objectText.includes('\n') || objectText.includes('\r');
|
|
193
|
+
|
|
194
|
+
if (!isMultiline) {
|
|
195
|
+
const content = remaining.map((prop) => sourceCode.getText(prop)).join(', ');
|
|
196
|
+
return (fixer) => fixer.replaceText(objectNode, `{ ${content} }`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const fullText = sourceCode.text;
|
|
200
|
+
const objectIndent = getLineIndent(fullText, objectNode.range[0]);
|
|
201
|
+
const propertyIndent = getLineIndent(fullText, remaining[0].range[0]) || `${objectIndent} `;
|
|
202
|
+
const lines = remaining.map((prop) => `${propertyIndent}${sourceCode.getText(prop)},`);
|
|
203
|
+
const replacement = `{\n${lines.join('\n')}\n${objectIndent}}`;
|
|
204
|
+
|
|
205
|
+
return (fixer) => fixer.replaceText(objectNode, replacement);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract optional prop names from the TS type behind defineProps<...>().
|
|
210
|
+
* This uses the TS type checker so it can follow type aliases/interfaces.
|
|
211
|
+
*/
|
|
212
|
+
function getOptionalPropNamesFromDefineProps(context, definePropsNode) {
|
|
213
|
+
const services = context.sourceCode.parserServices;
|
|
214
|
+
if (!services || !services.program || !services.esTreeNodeToTSNodeMap) return null;
|
|
215
|
+
|
|
216
|
+
const checker = services.program.getTypeChecker();
|
|
217
|
+
|
|
218
|
+
// Map the ESTree node to TS node
|
|
219
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(definePropsNode);
|
|
220
|
+
if (!tsNode) return null;
|
|
221
|
+
|
|
222
|
+
// defineProps<Props>() has typeArguments on the TS CallExpression node:
|
|
223
|
+
// tsNode.typeArguments?.[0]
|
|
224
|
+
const typeArg = tsNode.typeArguments && tsNode.typeArguments[0];
|
|
225
|
+
if (!typeArg) return null;
|
|
226
|
+
|
|
227
|
+
const type = checker.getTypeFromTypeNode(typeArg);
|
|
228
|
+
if (!type) return null;
|
|
229
|
+
|
|
230
|
+
const optional = new Set();
|
|
231
|
+
|
|
232
|
+
// Get properties of the type (handles interfaces, type aliases, intersections, etc.)
|
|
233
|
+
for (const sym of type.getProperties()) {
|
|
234
|
+
const name = sym.getName();
|
|
235
|
+
|
|
236
|
+
// Heuristic 1: SymbolFlags.Optional
|
|
237
|
+
if ((sym.getFlags() & ts.SymbolFlags.Optional) !== 0) {
|
|
238
|
+
optional.add(name);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Heuristic 2: inspect declarations for questionToken
|
|
243
|
+
const decls = sym.getDeclarations() || [];
|
|
244
|
+
for (const decl of decls) {
|
|
245
|
+
if (
|
|
246
|
+
ts.isPropertySignature(decl) ||
|
|
247
|
+
ts.isPropertyDeclaration(decl) ||
|
|
248
|
+
ts.isParameter(decl)
|
|
249
|
+
) {
|
|
250
|
+
if (decl.questionToken) {
|
|
251
|
+
optional.add(name);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return optional;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const rule = {
|
|
262
|
+
meta: {
|
|
263
|
+
type: 'problem',
|
|
264
|
+
fixable: 'code',
|
|
265
|
+
docs: {
|
|
266
|
+
description:
|
|
267
|
+
'Disallow disallowed (falsy) defaults for optional props in withDefaults(defineProps<...>(), { ... })',
|
|
268
|
+
},
|
|
269
|
+
schema: [
|
|
270
|
+
{
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
disallow: {
|
|
274
|
+
type: 'array',
|
|
275
|
+
items: {
|
|
276
|
+
type: 'string',
|
|
277
|
+
enum: ['undefined', 'false', 'null', "''", '0'],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
messages: {
|
|
285
|
+
disallowedDefault:
|
|
286
|
+
'Optional prop "{{name}}" must not be defaulted to {{value}}.',
|
|
287
|
+
requiresTypeInfo:
|
|
288
|
+
'@shrpne/vue-extra/no-falsy-default-with-optional-prop requires type information. Configure @typescript-eslint/parser with parserOptions.project.',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
create(context) {
|
|
293
|
+
const services = context.sourceCode.parserServices;
|
|
294
|
+
const hasTypeInfo = !!(services && services.program && services.esTreeNodeToTSNodeMap);
|
|
295
|
+
|
|
296
|
+
const disallow = new Set(
|
|
297
|
+
(context.options[0] && context.options[0].disallow) || DEFAULT_DISALLOW
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
function formatValue(node) {
|
|
301
|
+
if (node.type === 'Identifier' && node.name === 'undefined') return '`undefined`';
|
|
302
|
+
if (node.type === 'UnaryExpression' && node.operator === 'void') return '`undefined` (via `void`)';
|
|
303
|
+
if (node.type === 'Literal') {
|
|
304
|
+
if (node.value === false) return '`false`';
|
|
305
|
+
if (node.value === null) return '`null`';
|
|
306
|
+
if (node.value === 0) return '`0`';
|
|
307
|
+
if (node.value === '') return "empty string (`''`)";
|
|
308
|
+
}
|
|
309
|
+
if (node.type === 'TemplateLiteral') return "empty string (``)";
|
|
310
|
+
return 'a disallowed value';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
CallExpression(node) {
|
|
315
|
+
if (!isWithDefaultsCall(node)) return;
|
|
316
|
+
if (node.arguments.length < 2) return;
|
|
317
|
+
|
|
318
|
+
const [firstArg, secondArg] = node.arguments;
|
|
319
|
+
|
|
320
|
+
// withDefaults(defineProps<...>(), { ... })
|
|
321
|
+
if (!isDefinePropsCall(firstArg)) return;
|
|
322
|
+
if (!secondArg || secondArg.type !== 'ObjectExpression') return;
|
|
323
|
+
|
|
324
|
+
if (!hasTypeInfo) {
|
|
325
|
+
// Only report once per file at the first match; simplest approach is to report here.
|
|
326
|
+
context.report({ node, messageId: 'requiresTypeInfo' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const optionalProps = getOptionalPropNamesFromDefineProps(context, firstArg);
|
|
331
|
+
if (!optionalProps) return;
|
|
332
|
+
|
|
333
|
+
const violations = [];
|
|
334
|
+
for (const prop of secondArg.properties) {
|
|
335
|
+
if (prop.type !== 'Property') continue;
|
|
336
|
+
if (prop.computed) continue; // ignore computed keys
|
|
337
|
+
if (prop.kind !== 'init') continue;
|
|
338
|
+
|
|
339
|
+
const name = getPropNameFromKey(prop.key);
|
|
340
|
+
if (!name) continue;
|
|
341
|
+
|
|
342
|
+
// Only enforce for optional props
|
|
343
|
+
if (!optionalProps.has(name)) continue;
|
|
344
|
+
|
|
345
|
+
// If the default value is disallowed, report
|
|
346
|
+
if (isDisallowedValue(prop.value, disallow)) {
|
|
347
|
+
violations.push({ prop, name });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (violations.length === 0) return;
|
|
352
|
+
|
|
353
|
+
const allPropsToRemove = new Set(violations.map((violation) => violation.prop));
|
|
354
|
+
const sharedFix = buildReplaceObjectWithoutPropertiesFix(
|
|
355
|
+
context.sourceCode,
|
|
356
|
+
secondArg,
|
|
357
|
+
allPropsToRemove
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
for (const violation of violations) {
|
|
361
|
+
const fix = violations.length > 1
|
|
362
|
+
? sharedFix
|
|
363
|
+
: buildRemovePropertyFix(context.sourceCode, violation.prop);
|
|
364
|
+
context.report({
|
|
365
|
+
node: violation.prop.value,
|
|
366
|
+
messageId: 'disallowedDefault',
|
|
367
|
+
data: { name: violation.name, value: formatValue(violation.prop.value) },
|
|
368
|
+
fix,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export default rule;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
function getPropNameFromKey(key) {
|
|
4
|
+
if (!key) return null;
|
|
5
|
+
if (key.type === 'Identifier') return key.name;
|
|
6
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isWithDefaultsCall(node) {
|
|
11
|
+
return (
|
|
12
|
+
node &&
|
|
13
|
+
node.type === 'CallExpression' &&
|
|
14
|
+
node.callee &&
|
|
15
|
+
node.callee.type === 'Identifier' &&
|
|
16
|
+
node.callee.name === 'withDefaults'
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isDefinePropsCall(node) {
|
|
21
|
+
return (
|
|
22
|
+
node &&
|
|
23
|
+
node.type === 'CallExpression' &&
|
|
24
|
+
node.callee &&
|
|
25
|
+
node.callee.type === 'Identifier' &&
|
|
26
|
+
node.callee.name === 'defineProps'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isBooleanLikeType(type) {
|
|
31
|
+
if (!type) return false;
|
|
32
|
+
|
|
33
|
+
if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return true;
|
|
34
|
+
|
|
35
|
+
if ((type.flags & ts.TypeFlags.Union) !== 0 && type.types) {
|
|
36
|
+
return type.types.some((part) => (part.flags & ts.TypeFlags.BooleanLike) !== 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getOptionalFlag(symbol) {
|
|
43
|
+
if ((symbol.getFlags() & ts.SymbolFlags.Optional) !== 0) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const declarations = symbol.getDeclarations() || [];
|
|
48
|
+
for (const declaration of declarations) {
|
|
49
|
+
if (
|
|
50
|
+
ts.isPropertySignature(declaration) ||
|
|
51
|
+
ts.isPropertyDeclaration(declaration) ||
|
|
52
|
+
ts.isParameter(declaration)
|
|
53
|
+
) {
|
|
54
|
+
if (declaration.questionToken) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getPropMetadataFromDefineProps(context, definePropsNode) {
|
|
64
|
+
const services = context.sourceCode.parserServices;
|
|
65
|
+
if (!services || !services.program || !services.esTreeNodeToTSNodeMap) return null;
|
|
66
|
+
|
|
67
|
+
const checker = services.program.getTypeChecker();
|
|
68
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(definePropsNode);
|
|
69
|
+
if (!tsNode) return null;
|
|
70
|
+
|
|
71
|
+
const typeArg = tsNode.typeArguments && tsNode.typeArguments[0];
|
|
72
|
+
if (!typeArg) return null;
|
|
73
|
+
|
|
74
|
+
const type = checker.getTypeFromTypeNode(typeArg);
|
|
75
|
+
if (!type) return null;
|
|
76
|
+
|
|
77
|
+
const map = new Map();
|
|
78
|
+
for (const symbol of type.getProperties()) {
|
|
79
|
+
const name = symbol.getName();
|
|
80
|
+
const propType = checker.getTypeOfSymbolAtLocation(symbol, typeArg);
|
|
81
|
+
|
|
82
|
+
map.set(name, {
|
|
83
|
+
isBooleanLike: isBooleanLikeType(propType),
|
|
84
|
+
isOptional: getOptionalFlag(symbol),
|
|
85
|
+
symbol,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return map;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getInlineTypeLiteralMemberMap(definePropsNode) {
|
|
93
|
+
const typeArgs = definePropsNode.typeArguments;
|
|
94
|
+
if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) return new Map();
|
|
95
|
+
|
|
96
|
+
const firstTypeArg = typeArgs.params[0];
|
|
97
|
+
if (!firstTypeArg || firstTypeArg.type !== 'TSTypeLiteral') return new Map();
|
|
98
|
+
|
|
99
|
+
const members = new Map();
|
|
100
|
+
for (const member of firstTypeArg.members) {
|
|
101
|
+
if (member.type !== 'TSPropertySignature') continue;
|
|
102
|
+
if (!member.key || member.computed) continue;
|
|
103
|
+
|
|
104
|
+
const name = getPropNameFromKey(member.key);
|
|
105
|
+
if (!name) continue;
|
|
106
|
+
|
|
107
|
+
members.set(name, member);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return members;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildMarkOptionalFix(member) {
|
|
114
|
+
if (!member || member.type !== 'TSPropertySignature') return null;
|
|
115
|
+
if (member.optional) return null;
|
|
116
|
+
if (!member.key || !member.key.range) return null;
|
|
117
|
+
|
|
118
|
+
return (fixer) => fixer.insertTextAfterRange(member.key.range, '?');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildMarkOptionalFixFromSymbol(symbol, currentTsFileName) {
|
|
122
|
+
if (!symbol) return null;
|
|
123
|
+
|
|
124
|
+
const declarations = symbol.getDeclarations() || [];
|
|
125
|
+
for (const declaration of declarations) {
|
|
126
|
+
if (
|
|
127
|
+
!ts.isPropertySignature(declaration) &&
|
|
128
|
+
!ts.isPropertyDeclaration(declaration) &&
|
|
129
|
+
!ts.isParameter(declaration)
|
|
130
|
+
) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (declaration.questionToken) continue;
|
|
135
|
+
if (!declaration.name) continue;
|
|
136
|
+
|
|
137
|
+
const sourceFile = declaration.getSourceFile();
|
|
138
|
+
if (!sourceFile || sourceFile.fileName !== currentTsFileName) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const insertPos = declaration.name.getEnd();
|
|
143
|
+
return (fixer) => fixer.insertTextAfterRange([insertPos, insertPos], '?');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getBooleanDefaultsMap(defaultsNode) {
|
|
150
|
+
const map = new Map();
|
|
151
|
+
|
|
152
|
+
for (const prop of defaultsNode.properties) {
|
|
153
|
+
if (prop.type !== 'Property') continue;
|
|
154
|
+
if (prop.computed) continue;
|
|
155
|
+
if (prop.kind !== 'init') continue;
|
|
156
|
+
|
|
157
|
+
const name = getPropNameFromKey(prop.key);
|
|
158
|
+
if (!name) continue;
|
|
159
|
+
|
|
160
|
+
if (prop.value.type === 'Literal' && typeof prop.value.value === 'boolean') {
|
|
161
|
+
map.set(name, prop.value.value);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
map.set(name, null);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return map;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const preferOptionalBooleanProp = {
|
|
172
|
+
meta: {
|
|
173
|
+
type: 'suggestion',
|
|
174
|
+
fixable: 'code',
|
|
175
|
+
docs: {
|
|
176
|
+
description:
|
|
177
|
+
'Disallow required boolean props unless they are defaulted to true in withDefaults(...)',
|
|
178
|
+
},
|
|
179
|
+
schema: [
|
|
180
|
+
{
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
fixReferencedTypes: { type: 'boolean' },
|
|
184
|
+
},
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
messages: {
|
|
189
|
+
requiredBooleanNoDefault:
|
|
190
|
+
'Required boolean prop "{{name}}" should be optional unless it has a `true` default.',
|
|
191
|
+
requiredBooleanFalseDefault:
|
|
192
|
+
'Required boolean prop "{{name}}" must not have a `false` default; make it optional or default to `true`.',
|
|
193
|
+
requiresTypeInfo:
|
|
194
|
+
'@shrpne/vue-extra/prefer-optional-boolean-prop requires type information. Configure @typescript-eslint/parser with parserOptions.project.',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
create(context) {
|
|
199
|
+
const services = context.sourceCode.parserServices;
|
|
200
|
+
const hasTypeInfo = !!(services && services.program && services.esTreeNodeToTSNodeMap);
|
|
201
|
+
const fixReferencedTypes = !!(context.options[0] && context.options[0].fixReferencedTypes);
|
|
202
|
+
|
|
203
|
+
const programTsNode = hasTypeInfo
|
|
204
|
+
? services.esTreeNodeToTSNodeMap.get(context.sourceCode.ast)
|
|
205
|
+
: null;
|
|
206
|
+
const currentTsFileName = programTsNode ? programTsNode.getSourceFile().fileName : null;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
CallExpression(node) {
|
|
210
|
+
if (!isWithDefaultsCall(node)) return;
|
|
211
|
+
if (node.arguments.length < 2) return;
|
|
212
|
+
|
|
213
|
+
const [firstArg, secondArg] = node.arguments;
|
|
214
|
+
if (!isDefinePropsCall(firstArg)) return;
|
|
215
|
+
if (!secondArg || secondArg.type !== 'ObjectExpression') return;
|
|
216
|
+
|
|
217
|
+
if (!hasTypeInfo) {
|
|
218
|
+
context.report({ node, messageId: 'requiresTypeInfo' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const propMetadataMap = getPropMetadataFromDefineProps(context, firstArg);
|
|
223
|
+
if (!propMetadataMap) return;
|
|
224
|
+
|
|
225
|
+
const inlineMembers = getInlineTypeLiteralMemberMap(firstArg);
|
|
226
|
+
const booleanDefaultsMap = getBooleanDefaultsMap(secondArg);
|
|
227
|
+
|
|
228
|
+
for (const [name, metadata] of propMetadataMap.entries()) {
|
|
229
|
+
if (!metadata.isBooleanLike) continue;
|
|
230
|
+
if (metadata.isOptional) continue;
|
|
231
|
+
|
|
232
|
+
const hasDefault = booleanDefaultsMap.has(name);
|
|
233
|
+
const defaultValue = hasDefault ? booleanDefaultsMap.get(name) : null;
|
|
234
|
+
|
|
235
|
+
if (defaultValue === true) continue;
|
|
236
|
+
|
|
237
|
+
const member = inlineMembers.get(name);
|
|
238
|
+
const inlineFix = buildMarkOptionalFix(member);
|
|
239
|
+
const typeDefinitionFix = fixReferencedTypes && currentTsFileName
|
|
240
|
+
? buildMarkOptionalFixFromSymbol(metadata.symbol, currentTsFileName)
|
|
241
|
+
: null;
|
|
242
|
+
const fix = inlineFix || typeDefinitionFix || null;
|
|
243
|
+
|
|
244
|
+
context.report({
|
|
245
|
+
node: member || node,
|
|
246
|
+
messageId: defaultValue === false
|
|
247
|
+
? 'requiredBooleanFalseDefault'
|
|
248
|
+
: 'requiredBooleanNoDefault',
|
|
249
|
+
data: { name },
|
|
250
|
+
fix,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export default preferOptionalBooleanProp;
|