@soybeanjs/oxc-config 0.0.5 → 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/dist/index.js +10 -3
- package/dist/plugins/import.d.ts +67 -0
- package/dist/plugins/import.js +288 -0
- package/package.json +6 -5
- package/dist/import-type-order.js +0 -112
package/dist/index.js
CHANGED
|
@@ -207,12 +207,19 @@ const lint = {
|
|
|
207
207
|
"import",
|
|
208
208
|
"vue"
|
|
209
209
|
],
|
|
210
|
-
jsPlugins: ["@soybeanjs/oxc-config/import
|
|
210
|
+
jsPlugins: ["@soybeanjs/oxc-config/plugins/import"],
|
|
211
211
|
rules: {
|
|
212
|
+
"soybeanjs-import/merge-duplicates": "warn",
|
|
213
|
+
"soybeanjs-import/no-inline-type-import": "warn",
|
|
214
|
+
"soybeanjs-import/type-after-value": "warn",
|
|
215
|
+
"import/newline-after-import": "warn",
|
|
212
216
|
"import/no-unassigned-import": "off",
|
|
213
|
-
"import-type-order/type-after-value": "warn",
|
|
214
217
|
"no-underscore-dangle": "off",
|
|
215
|
-
"
|
|
218
|
+
"typescript/consistent-type-imports": ["error", {
|
|
219
|
+
disallowTypeAnnotations: false,
|
|
220
|
+
prefer: "type-imports",
|
|
221
|
+
fixStyle: "separate-type-imports"
|
|
222
|
+
}],
|
|
216
223
|
"unicorn/consistent-function-scoping": "off",
|
|
217
224
|
"unicorn/no-array-reverse": "off",
|
|
218
225
|
"unicorn/no-array-sort": "off",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/plugins/import/shared/types.d.ts
|
|
2
|
+
interface Position {
|
|
3
|
+
line: number;
|
|
4
|
+
}
|
|
5
|
+
interface SourceLocation {
|
|
6
|
+
start: Position;
|
|
7
|
+
end: Position;
|
|
8
|
+
}
|
|
9
|
+
interface BaseNode {
|
|
10
|
+
type: string;
|
|
11
|
+
range: [number, number];
|
|
12
|
+
loc: SourceLocation;
|
|
13
|
+
}
|
|
14
|
+
interface CommentNode {
|
|
15
|
+
range: [number, number];
|
|
16
|
+
}
|
|
17
|
+
interface ProgramNode extends BaseNode {
|
|
18
|
+
body: BaseNode[];
|
|
19
|
+
}
|
|
20
|
+
interface SourceCodeLike {
|
|
21
|
+
text: string;
|
|
22
|
+
ast: ProgramNode;
|
|
23
|
+
getText(node?: {
|
|
24
|
+
range: [number, number];
|
|
25
|
+
} | null): string;
|
|
26
|
+
getCommentsBefore(node: BaseNode): CommentNode[];
|
|
27
|
+
}
|
|
28
|
+
interface FixerLike {
|
|
29
|
+
replaceTextRange(range: [number, number], text: string): unknown;
|
|
30
|
+
replaceText(node: BaseNode, text: string): unknown;
|
|
31
|
+
}
|
|
32
|
+
interface ReportDescriptor {
|
|
33
|
+
node: BaseNode;
|
|
34
|
+
messageId: string;
|
|
35
|
+
data?: Record<string, string>;
|
|
36
|
+
fix?: (fixer: FixerLike) => unknown;
|
|
37
|
+
}
|
|
38
|
+
interface RuleContextLike {
|
|
39
|
+
sourceCode: SourceCodeLike;
|
|
40
|
+
report(descriptor: ReportDescriptor): void;
|
|
41
|
+
}
|
|
42
|
+
type VisitorLike = Record<string, ((node: any) => void) | undefined>;
|
|
43
|
+
interface RuleMetaLike {
|
|
44
|
+
type: string;
|
|
45
|
+
fixable?: 'code';
|
|
46
|
+
docs?: {
|
|
47
|
+
description: string;
|
|
48
|
+
url?: string;
|
|
49
|
+
};
|
|
50
|
+
messages?: Record<string, string>;
|
|
51
|
+
schema?: unknown[];
|
|
52
|
+
}
|
|
53
|
+
interface RuleLike {
|
|
54
|
+
meta?: RuleMetaLike;
|
|
55
|
+
create: (context: RuleContextLike) => VisitorLike;
|
|
56
|
+
}
|
|
57
|
+
interface PluginLike {
|
|
58
|
+
meta: {
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
rules: Record<string, RuleLike>;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/plugins/import/index.d.ts
|
|
65
|
+
declare const plugin: PluginLike;
|
|
66
|
+
//#endregion
|
|
67
|
+
export { plugin as default };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
//#region src/plugins/import/shared/imports.ts
|
|
2
|
+
function isTypeKind(kind) {
|
|
3
|
+
return kind === "type" || kind === "typeof";
|
|
4
|
+
}
|
|
5
|
+
function isImportDeclaration(node) {
|
|
6
|
+
return Boolean(node) && typeof node === "object" && node.type === "ImportDeclaration";
|
|
7
|
+
}
|
|
8
|
+
function isTypeImport(node) {
|
|
9
|
+
return isTypeKind(node.importKind);
|
|
10
|
+
}
|
|
11
|
+
function isInlineTypeSpecifier(node) {
|
|
12
|
+
return node.type === "ImportSpecifier" && isTypeKind(node.importKind);
|
|
13
|
+
}
|
|
14
|
+
function getInlineTypeSpecifiers(node) {
|
|
15
|
+
return (node.specifiers ?? []).filter(isInlineTypeSpecifier);
|
|
16
|
+
}
|
|
17
|
+
function hasInlineTypeSpecifier(node) {
|
|
18
|
+
return !isTypeImport(node) && getInlineTypeSpecifiers(node).length > 0;
|
|
19
|
+
}
|
|
20
|
+
function stripInlineTypeKeyword(text) {
|
|
21
|
+
return text.replace(/^type\s+/, "");
|
|
22
|
+
}
|
|
23
|
+
function buildImportStatement(options) {
|
|
24
|
+
const { importKeyword, specifierTexts, sourceText } = options;
|
|
25
|
+
return `${importKeyword} ${specifierTexts.join(", ")} from ${sourceText};`;
|
|
26
|
+
}
|
|
27
|
+
function buildNamedImportText(specifiers, sourceCode, options = {}) {
|
|
28
|
+
return `{ ${specifiers.map((specifier) => {
|
|
29
|
+
const text = sourceCode.getText(specifier);
|
|
30
|
+
return options.stripTypeKeyword ? stripInlineTypeKeyword(text) : text;
|
|
31
|
+
}).join(", ")} }`;
|
|
32
|
+
}
|
|
33
|
+
function buildValueImportStatement(node, sourceCode) {
|
|
34
|
+
const specifiers = node.specifiers ?? [];
|
|
35
|
+
const defaultSpecifiers = specifiers.filter((specifier) => {
|
|
36
|
+
return specifier.type === "ImportDefaultSpecifier";
|
|
37
|
+
}).map((specifier) => sourceCode.getText(specifier));
|
|
38
|
+
const namespaceSpecifiers = specifiers.filter((specifier) => {
|
|
39
|
+
return specifier.type === "ImportNamespaceSpecifier";
|
|
40
|
+
}).map((specifier) => sourceCode.getText(specifier));
|
|
41
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === "ImportSpecifier" && !isInlineTypeSpecifier(specifier));
|
|
42
|
+
const specifierTexts = [...defaultSpecifiers, ...namespaceSpecifiers];
|
|
43
|
+
if (namedSpecifiers.length > 0) specifierTexts.push(buildNamedImportText(namedSpecifiers, sourceCode));
|
|
44
|
+
if (specifierTexts.length === 0) return null;
|
|
45
|
+
return buildImportStatement({
|
|
46
|
+
importKeyword: "import",
|
|
47
|
+
specifierTexts,
|
|
48
|
+
sourceText: sourceCode.getText(node.source)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function buildTypeImportStatement(node, sourceCode) {
|
|
52
|
+
const inlineTypeSpecifiers = getInlineTypeSpecifiers(node);
|
|
53
|
+
if (inlineTypeSpecifiers.length === 0) return null;
|
|
54
|
+
return buildImportStatement({
|
|
55
|
+
importKeyword: "import type",
|
|
56
|
+
specifierTexts: [buildNamedImportText(inlineTypeSpecifiers, sourceCode, { stripTypeKeyword: true })],
|
|
57
|
+
sourceText: sourceCode.getText(node.source)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function canFixInlineTypeImport(node) {
|
|
61
|
+
return !isTypeImport(node) && node.specifiers != null && hasInlineTypeSpecifier(node) && node.withClause == null;
|
|
62
|
+
}
|
|
63
|
+
function createImportStatementParts() {
|
|
64
|
+
return {
|
|
65
|
+
defaultImports: [],
|
|
66
|
+
namespaceImports: [],
|
|
67
|
+
namedImports: []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function pushUnique(values, text) {
|
|
71
|
+
if (!values.includes(text)) values.push(text);
|
|
72
|
+
}
|
|
73
|
+
function appendSpecifierText(target, specifier, sourceCode) {
|
|
74
|
+
if (specifier.type === "ImportDefaultSpecifier") {
|
|
75
|
+
pushUnique(target.defaultImports, sourceCode.getText(specifier));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (specifier.type === "ImportNamespaceSpecifier") {
|
|
79
|
+
pushUnique(target.namespaceImports, sourceCode.getText(specifier));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
pushUnique(target.namedImports, stripInlineTypeKeyword(sourceCode.getText(specifier)));
|
|
83
|
+
}
|
|
84
|
+
function canBuildSingleImport(parts) {
|
|
85
|
+
if (parts.defaultImports.length > 1 || parts.namespaceImports.length > 1) return false;
|
|
86
|
+
if (parts.namespaceImports.length > 0 && parts.namedImports.length > 0) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
function buildImportFromParts(parts, sourceText, importKeyword) {
|
|
90
|
+
if (!canBuildSingleImport(parts)) return null;
|
|
91
|
+
const specifierTexts = [...parts.defaultImports, ...parts.namespaceImports];
|
|
92
|
+
if (parts.namedImports.length > 0) specifierTexts.push(`{ ${parts.namedImports.join(", ")} }`);
|
|
93
|
+
if (specifierTexts.length === 0) return null;
|
|
94
|
+
return buildImportStatement({
|
|
95
|
+
importKeyword,
|
|
96
|
+
specifierTexts,
|
|
97
|
+
sourceText
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function mergeImportGroup(group, sourceCode) {
|
|
101
|
+
if (group.length < 2) return null;
|
|
102
|
+
const valueParts = createImportStatementParts();
|
|
103
|
+
const typeParts = createImportStatementParts();
|
|
104
|
+
const sourceText = sourceCode.getText(group[0].node.source);
|
|
105
|
+
for (const item of group) {
|
|
106
|
+
if (item.node.withClause != null || (item.node.specifiers?.length ?? 0) === 0) return null;
|
|
107
|
+
if (sourceCode.getCommentsBefore(item.node).length > 0 && item !== group[0]) return null;
|
|
108
|
+
for (const specifier of item.node.specifiers ?? []) if (isTypeImport(item.node) || isInlineTypeSpecifier(specifier)) appendSpecifierText(typeParts, specifier, sourceCode);
|
|
109
|
+
else appendSpecifierText(valueParts, specifier, sourceCode);
|
|
110
|
+
}
|
|
111
|
+
const mergedStatements = [buildImportFromParts(valueParts, sourceText, "import"), buildImportFromParts(typeParts, sourceText, "import type")].filter((statement) => Boolean(statement));
|
|
112
|
+
if (mergedStatements.length === 0 || mergedStatements.length >= group.length) return null;
|
|
113
|
+
return mergedStatements;
|
|
114
|
+
}
|
|
115
|
+
function collectImportGroups(sourceCode) {
|
|
116
|
+
const allImports = [];
|
|
117
|
+
for (let index = 0; index < sourceCode.ast.body.length; index += 1) {
|
|
118
|
+
const node = sourceCode.ast.body[index];
|
|
119
|
+
if (isImportDeclaration(node)) allImports.push({
|
|
120
|
+
node,
|
|
121
|
+
bodyIndex: index
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (allImports.length < 2) return [];
|
|
125
|
+
const groups = [];
|
|
126
|
+
let currentGroup = [allImports[0]];
|
|
127
|
+
for (let index = 1; index < allImports.length; index += 1) {
|
|
128
|
+
const previous = allImports[index - 1];
|
|
129
|
+
const current = allImports[index];
|
|
130
|
+
const linesBetween = current.node.loc.start.line - previous.node.loc.end.line;
|
|
131
|
+
if (previous.node.source.value === current.node.source.value && linesBetween <= 1) currentGroup.push(current);
|
|
132
|
+
else {
|
|
133
|
+
groups.push(currentGroup);
|
|
134
|
+
currentGroup = [current];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
groups.push(currentGroup);
|
|
138
|
+
return groups;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/plugins/import/shared/source.ts
|
|
143
|
+
function getStartWithComments(node, sourceCode) {
|
|
144
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
145
|
+
if (comments.length > 0) return comments[0].range[0];
|
|
146
|
+
return node.range[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/plugins/import/rules/merge-duplicates.ts
|
|
151
|
+
const mergeDuplicatesRule = {
|
|
152
|
+
meta: {
|
|
153
|
+
type: "layout",
|
|
154
|
+
fixable: "code",
|
|
155
|
+
docs: {
|
|
156
|
+
description: "Merge duplicate imports from the same module into at most one value import and one type import.",
|
|
157
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/merge-duplicates.ts"
|
|
158
|
+
},
|
|
159
|
+
messages: { mergeDuplicates: "Imports from \"{{source}}\" should be merged into at most one value import and one type import." },
|
|
160
|
+
schema: []
|
|
161
|
+
},
|
|
162
|
+
create(context) {
|
|
163
|
+
const sourceCode = context.sourceCode;
|
|
164
|
+
return { "Program:exit"() {
|
|
165
|
+
const groups = collectImportGroups(sourceCode);
|
|
166
|
+
for (const group of groups) {
|
|
167
|
+
const mergedStatements = mergeImportGroup(group, sourceCode);
|
|
168
|
+
if (!mergedStatements) continue;
|
|
169
|
+
const firstNode = group[0].node;
|
|
170
|
+
const lastNode = group[group.length - 1].node;
|
|
171
|
+
context.report({
|
|
172
|
+
node: firstNode,
|
|
173
|
+
messageId: "mergeDuplicates",
|
|
174
|
+
data: { source: firstNode.source.value },
|
|
175
|
+
fix(fixer) {
|
|
176
|
+
return fixer.replaceTextRange([getStartWithComments(firstNode, sourceCode), lastNode.range[1]], mergedStatements.join("\n"));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/plugins/import/rules/no-inline-type-import.ts
|
|
186
|
+
const noInlineTypeImportRule = {
|
|
187
|
+
meta: {
|
|
188
|
+
type: "layout",
|
|
189
|
+
fixable: "code",
|
|
190
|
+
docs: {
|
|
191
|
+
description: "Prefer top-level `import type` over inline `type` specifiers, including mixed value/type imports.",
|
|
192
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/no-inline-type-import.ts"
|
|
193
|
+
},
|
|
194
|
+
messages: { noInlineTypeImport: "Inline type import from \"{{source}}\" should use top-level `import type` declarations instead." },
|
|
195
|
+
schema: []
|
|
196
|
+
},
|
|
197
|
+
create(context) {
|
|
198
|
+
const sourceCode = context.sourceCode;
|
|
199
|
+
return { ImportDeclaration(node) {
|
|
200
|
+
if (!canFixInlineTypeImport(node)) return;
|
|
201
|
+
context.report({
|
|
202
|
+
node,
|
|
203
|
+
messageId: "noInlineTypeImport",
|
|
204
|
+
data: { source: node.source.value },
|
|
205
|
+
fix(fixer) {
|
|
206
|
+
const parts = [buildValueImportStatement(node, sourceCode), buildTypeImportStatement(node, sourceCode)].filter(Boolean);
|
|
207
|
+
return fixer.replaceText(node, parts.join("\n"));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/plugins/import/rules/type-after-value.ts
|
|
216
|
+
const typeAfterValueRule = {
|
|
217
|
+
meta: {
|
|
218
|
+
type: "layout",
|
|
219
|
+
fixable: "code",
|
|
220
|
+
docs: {
|
|
221
|
+
description: "Ensure value imports come before type imports for the same module source.",
|
|
222
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/type-after-value.ts"
|
|
223
|
+
},
|
|
224
|
+
messages: { typeBeforeValue: "Type import from \"{{source}}\" must be placed after its value import." },
|
|
225
|
+
schema: []
|
|
226
|
+
},
|
|
227
|
+
create(context) {
|
|
228
|
+
const sourceCode = context.sourceCode;
|
|
229
|
+
return { "Program:exit"() {
|
|
230
|
+
const groups = collectImportGroups(sourceCode);
|
|
231
|
+
for (const group of groups) {
|
|
232
|
+
if (group.length < 2) continue;
|
|
233
|
+
if (!group.some((item, index) => {
|
|
234
|
+
if (!isTypeImport(item.node)) return false;
|
|
235
|
+
for (let cursor = index + 1; cursor < group.length; cursor += 1) if (!isTypeImport(group[cursor].node)) return true;
|
|
236
|
+
return false;
|
|
237
|
+
})) continue;
|
|
238
|
+
const sorted = [...group].sort((left, right) => {
|
|
239
|
+
const leftIsType = isTypeImport(left.node);
|
|
240
|
+
if (leftIsType === isTypeImport(right.node)) return 0;
|
|
241
|
+
return leftIsType ? 1 : -1;
|
|
242
|
+
});
|
|
243
|
+
const firstNode = group[0].node;
|
|
244
|
+
const lastNode = group[group.length - 1].node;
|
|
245
|
+
context.report({
|
|
246
|
+
node: firstNode,
|
|
247
|
+
messageId: "typeBeforeValue",
|
|
248
|
+
data: { source: firstNode.source.value },
|
|
249
|
+
fix(fixer) {
|
|
250
|
+
const parts = [];
|
|
251
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
252
|
+
const item = sorted[index];
|
|
253
|
+
const previousItem = index > 0 ? sorted[index - 1] : null;
|
|
254
|
+
const start = getStartWithComments(item.node, sourceCode);
|
|
255
|
+
const text = sourceCode.text.slice(start, item.node.range[1]);
|
|
256
|
+
if (index > 0 && previousItem) {
|
|
257
|
+
const previousOriginalIndex = group.findIndex((candidate) => candidate === previousItem);
|
|
258
|
+
const currentOriginalIndex = group.findIndex((candidate) => candidate === item);
|
|
259
|
+
if (Math.abs(currentOriginalIndex - previousOriginalIndex) === 1) {
|
|
260
|
+
const earlier = group[Math.min(previousOriginalIndex, currentOriginalIndex)];
|
|
261
|
+
const later = group[Math.max(previousOriginalIndex, currentOriginalIndex)];
|
|
262
|
+
const separator = sourceCode.text.slice(earlier.node.range[1], getStartWithComments(later.node, sourceCode));
|
|
263
|
+
parts.push(separator);
|
|
264
|
+
} else parts.push("\n");
|
|
265
|
+
}
|
|
266
|
+
parts.push(text);
|
|
267
|
+
}
|
|
268
|
+
return fixer.replaceTextRange([getStartWithComments(firstNode, sourceCode), lastNode.range[1]], parts.join(""));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
} };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/plugins/import/index.ts
|
|
278
|
+
const plugin = {
|
|
279
|
+
meta: { name: "soybeanjs-import" },
|
|
280
|
+
rules: {
|
|
281
|
+
"merge-duplicates": mergeDuplicatesRule,
|
|
282
|
+
"no-inline-type-import": noInlineTypeImportRule,
|
|
283
|
+
"type-after-value": typeAfterValueRule
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
export { plugin as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soybeanjs/oxc-config",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "SoybeanJS's oxlint and oxfmt config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
"import": "./dist/index.js",
|
|
36
36
|
"require": "./dist/index.js"
|
|
37
37
|
},
|
|
38
|
-
"./
|
|
39
|
-
"
|
|
40
|
-
"
|
|
38
|
+
"./plugins/*": {
|
|
39
|
+
"types": "./dist/plugins/*.d.ts",
|
|
40
|
+
"import": "./dist/plugins/*.js",
|
|
41
|
+
"require": "./dist/plugins/*.js"
|
|
41
42
|
}
|
|
42
43
|
},
|
|
43
44
|
"publishConfig": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@soybeanjs/cli": "^1.7.2",
|
|
49
50
|
"@soybeanjs/oxc-config": "link:dist",
|
|
50
|
-
"@types/node": "^25.9.
|
|
51
|
+
"@types/node": "^25.9.1",
|
|
51
52
|
"lint-staged": "^17.0.5",
|
|
52
53
|
"oxfmt": "^0.51.0",
|
|
53
54
|
"oxlint": "^1.66.0",
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
//#region src/import-type-order.js
|
|
2
|
-
/**
|
|
3
|
-
* oxlint JS plugin: import-type-order
|
|
4
|
-
*
|
|
5
|
-
* 确保同一模块的 `import type` 始终在 `import` (value import) 之后。
|
|
6
|
-
* 与 oxfmt 的 sortImports 配合使用:oxfmt 负责分组排序,此插件负责同模块 value-before-type。
|
|
7
|
-
*
|
|
8
|
-
* 基于 eslint-plugin-simple-import-sort 的核心思路实现。
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* 判断 import 是否为 type-only import(`import type { X } from '...'`)
|
|
12
|
-
* 也兼容 `import typeof`(Flow 语法)。
|
|
13
|
-
*/
|
|
14
|
-
function isTypeImport(node) {
|
|
15
|
-
return node.importKind === "type" || node.importKind === "typeof";
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* 获取 import 声明前附着的注释的起始位置。
|
|
19
|
-
* 如果没有前导注释,返回节点自身的起始位置。
|
|
20
|
-
*/
|
|
21
|
-
function getStartWithComments(node, sourceCode) {
|
|
22
|
-
const comments = sourceCode.getCommentsBefore(node);
|
|
23
|
-
if (comments.length > 0) return comments[0].range[0];
|
|
24
|
-
return node.range[0];
|
|
25
|
-
}
|
|
26
|
-
const plugin = {
|
|
27
|
-
meta: { name: "import-type-order" },
|
|
28
|
-
rules: { "type-after-value": {
|
|
29
|
-
meta: {
|
|
30
|
-
type: "layout",
|
|
31
|
-
fixable: "code",
|
|
32
|
-
docs: {
|
|
33
|
-
description: "Ensure value imports come before type imports for the same module source.",
|
|
34
|
-
url: "https://github.com/soybeanjs/oxfmt-import-order"
|
|
35
|
-
},
|
|
36
|
-
messages: { typeBeforeValue: "Type import from \"{{source}}\" must be placed after its value import." },
|
|
37
|
-
schema: []
|
|
38
|
-
},
|
|
39
|
-
create(context) {
|
|
40
|
-
const sourceCode = context.sourceCode;
|
|
41
|
-
return { "Program:exit"() {
|
|
42
|
-
const body = sourceCode.ast.body;
|
|
43
|
-
const allImports = [];
|
|
44
|
-
for (let i = 0; i < body.length; i++) if (body[i].type === "ImportDeclaration") allImports.push({
|
|
45
|
-
node: body[i],
|
|
46
|
-
bodyIndex: i
|
|
47
|
-
});
|
|
48
|
-
if (allImports.length < 2) return;
|
|
49
|
-
const groups = [];
|
|
50
|
-
let currentGroup = [allImports[0]];
|
|
51
|
-
for (let i = 1; i < allImports.length; i++) {
|
|
52
|
-
const prev = allImports[i - 1];
|
|
53
|
-
const curr = allImports[i];
|
|
54
|
-
const linesBetween = curr.node.loc.start.line - prev.node.loc.end.line;
|
|
55
|
-
if (prev.node.source.value === curr.node.source.value && linesBetween <= 1) currentGroup.push(curr);
|
|
56
|
-
else {
|
|
57
|
-
groups.push(currentGroup);
|
|
58
|
-
currentGroup = [curr];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
groups.push(currentGroup);
|
|
62
|
-
for (const group of groups) {
|
|
63
|
-
if (group.length < 2) continue;
|
|
64
|
-
if (!group.some((item, i) => {
|
|
65
|
-
if (!isTypeImport(item.node)) return false;
|
|
66
|
-
for (let j = i + 1; j < group.length; j++) if (!isTypeImport(group[j].node)) return true;
|
|
67
|
-
return false;
|
|
68
|
-
})) continue;
|
|
69
|
-
const sorted = [...group].sort((a, b) => {
|
|
70
|
-
const aIsType = isTypeImport(a.node);
|
|
71
|
-
if (aIsType === isTypeImport(b.node)) return 0;
|
|
72
|
-
return aIsType ? 1 : -1;
|
|
73
|
-
});
|
|
74
|
-
const firstNode = group[0].node;
|
|
75
|
-
const lastNode = group[group.length - 1].node;
|
|
76
|
-
const source = firstNode.source.value;
|
|
77
|
-
context.report({
|
|
78
|
-
node: firstNode,
|
|
79
|
-
messageId: "typeBeforeValue",
|
|
80
|
-
data: { source },
|
|
81
|
-
fix(fixer) {
|
|
82
|
-
const parts = [];
|
|
83
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
84
|
-
const item = sorted[i];
|
|
85
|
-
const prevItem = i > 0 ? sorted[i - 1] : null;
|
|
86
|
-
const start = getStartWithComments(item.node, sourceCode);
|
|
87
|
-
const text = sourceCode.text.slice(start, item.node.range[1]);
|
|
88
|
-
if (i > 0) {
|
|
89
|
-
const prevOriginalIndex = group.findIndex((g) => g === prevItem);
|
|
90
|
-
const currOriginalIndex = group.findIndex((g) => g === item);
|
|
91
|
-
if (Math.abs(currOriginalIndex - prevOriginalIndex) === 1) {
|
|
92
|
-
const earlier = group[Math.min(prevOriginalIndex, currOriginalIndex)];
|
|
93
|
-
const later = group[Math.max(prevOriginalIndex, currOriginalIndex)];
|
|
94
|
-
const sep = sourceCode.text.slice(earlier.node.range[1], getStartWithComments(later.node, sourceCode));
|
|
95
|
-
parts.push(sep);
|
|
96
|
-
} else parts.push("\n");
|
|
97
|
-
}
|
|
98
|
-
parts.push(text);
|
|
99
|
-
}
|
|
100
|
-
const rangeStart = getStartWithComments(firstNode, sourceCode);
|
|
101
|
-
const rangeEnd = lastNode.range[1];
|
|
102
|
-
return fixer.replaceTextRange([rangeStart, rangeEnd], parts.join(""));
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
} };
|
|
107
|
-
}
|
|
108
|
-
} }
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
//#endregion
|
|
112
|
-
export { plugin as default };
|