eslint-plugin-imports-regulation 0.2.9 → 0.2.11
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.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
-
type MessageIds = 'groupOrder' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'blankLineBelowComment' | 'typeSpecifiersLast' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter';
|
|
2
|
+
type MessageIds = 'groupOrder' | 'exportBlock' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'blankLineBelowComment' | 'typeSpecifiersLast' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter';
|
|
3
3
|
type Origin = 'external' | 'internal';
|
|
4
4
|
type Options = [
|
|
5
5
|
{
|
|
@@ -64,6 +64,7 @@ const rule = {
|
|
|
64
64
|
],
|
|
65
65
|
messages: {
|
|
66
66
|
groupOrder: 'Expected this {{group}} import before the {{other}} imports.',
|
|
67
|
+
exportBlock: 'Expected this export from an {{origin}} module with the {{origin}} imports.',
|
|
67
68
|
blankLinesBetween: 'Expected {{expected}} blank {{lines}} between external imports and internal imports, found {{actual}}.',
|
|
68
69
|
unexpectedBlankLine: 'Expected no blank lines between imports in the same group, found {{actual}}.',
|
|
69
70
|
blankLineBelowComment: 'Expected no blank lines between a comment and the import it belongs to, found {{actual}}.',
|
|
@@ -243,7 +244,9 @@ const rule = {
|
|
|
243
244
|
for (const variable of source.getDeclaredVariables(node)) {
|
|
244
245
|
if (variable.references.length === 0)
|
|
245
246
|
continue;
|
|
246
|
-
|
|
247
|
+
// A plain `export { x }` is flagged as both, and could be a value.
|
|
248
|
+
const typeOnly = variable.references.every(reference => reference.isTypeReference && !reference.isValueReference);
|
|
249
|
+
if (typeOnly)
|
|
247
250
|
names.add(variable.name);
|
|
248
251
|
}
|
|
249
252
|
return names;
|
|
@@ -335,6 +338,8 @@ const rule = {
|
|
|
335
338
|
fix: fixer => fixer.replaceTextRange(range, rejoin(range, named, [...values, ...types], specifier => source.getText(specifier))),
|
|
336
339
|
});
|
|
337
340
|
};
|
|
341
|
+
/** `export … from` and `export { … }`, which sit among the imports; a declaration is code. */
|
|
342
|
+
const isHeaderExport = (node) => node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.declaration === null);
|
|
338
343
|
/** Comments that mean something only where they are, so they never move with an import. */
|
|
339
344
|
const staysPut = (comment) => {
|
|
340
345
|
if (source.text.startsWith('#!', comment.range[0]))
|
|
@@ -386,8 +391,22 @@ const rule = {
|
|
|
386
391
|
break;
|
|
387
392
|
end = comment.range[1];
|
|
388
393
|
}
|
|
389
|
-
|
|
394
|
+
const isImport = node.type === 'ImportDeclaration';
|
|
395
|
+
entries.push({ node, isImport, group: isImport ? groupOf(node) : -1, start, end });
|
|
390
396
|
}
|
|
397
|
+
// An export next to an import from the same module is bound to it, through any run of such exports.
|
|
398
|
+
const sourceOf = (entry) => entry?.node.source?.value;
|
|
399
|
+
const bind = (ordered) => {
|
|
400
|
+
for (const [i, entry] of ordered.entries()) {
|
|
401
|
+
const neighbour = ordered[i - 1];
|
|
402
|
+
if (entry.isImport || entry.group !== -1 || !neighbour || neighbour.group === -1)
|
|
403
|
+
continue;
|
|
404
|
+
if (sourceOf(entry) !== undefined && sourceOf(entry) === sourceOf(neighbour))
|
|
405
|
+
entry.group = neighbour.group;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
bind(entries);
|
|
409
|
+
bind([...entries].reverse());
|
|
391
410
|
return entries;
|
|
392
411
|
};
|
|
393
412
|
/**
|
|
@@ -465,18 +484,58 @@ const rule = {
|
|
|
465
484
|
fix: fixer => fixer.replaceTextRange([from, to], eol.repeat(blankLinesAfter + 1)),
|
|
466
485
|
});
|
|
467
486
|
};
|
|
468
|
-
const checkChunk = (chunk) => {
|
|
487
|
+
const checkChunk = (chunk, leading) => {
|
|
469
488
|
const entries = entriesOf(chunk);
|
|
470
489
|
const first = entries[0];
|
|
471
490
|
const last = entries[entries.length - 1];
|
|
472
491
|
if (!first || !last)
|
|
473
492
|
return;
|
|
474
|
-
|
|
493
|
+
// An import after code is not the imports block, so no gap is owed after it.
|
|
494
|
+
if (leading)
|
|
495
|
+
checkAfterImports(last);
|
|
475
496
|
if (entries.length < 2)
|
|
476
497
|
return;
|
|
498
|
+
const blankBetween = (above, below) => {
|
|
499
|
+
const gap = source.text.slice(above.end, below.start);
|
|
500
|
+
return Math.max((gap.match(BREAK_GLOBAL) ?? []).length - 1, 0);
|
|
501
|
+
};
|
|
502
|
+
// Exports below every import, past a blank line, are a block of their own and left alone.
|
|
503
|
+
let lastImport = -1;
|
|
504
|
+
for (const [i, entry] of entries.entries())
|
|
505
|
+
if (entry.isImport)
|
|
506
|
+
lastImport = i;
|
|
507
|
+
let tail = entries.length;
|
|
508
|
+
for (let i = lastImport + 1; i < entries.length; i++) {
|
|
509
|
+
const above = entries[i - 1];
|
|
510
|
+
const here = entries[i];
|
|
511
|
+
if (above && here && blankBetween(above, here) > 0) {
|
|
512
|
+
tail = i;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const region = entries.slice(0, tail);
|
|
517
|
+
const regionLast = region[region.length - 1] ?? last;
|
|
518
|
+
// An unpaired export belongs with its module's imports; one with no module stays with its neighbour.
|
|
519
|
+
const sited = new Set();
|
|
520
|
+
for (const entry of region) {
|
|
521
|
+
if (entry.group !== -1 || entry.node.type === 'ImportDeclaration' || !entry.node.source)
|
|
522
|
+
continue;
|
|
523
|
+
const base = originOf(entry.node.source.value) === 'internal' ? INTERNAL_TYPE : EXTERNAL_TYPE;
|
|
524
|
+
entry.group = entry.node.exportKind === 'type' ? base : base + 1;
|
|
525
|
+
sited.add(entry);
|
|
526
|
+
}
|
|
527
|
+
for (const ordered of [region, [...region].reverse()]) {
|
|
528
|
+
let neighbour = -1;
|
|
529
|
+
for (const entry of ordered) {
|
|
530
|
+
if (entry.group === -1 && neighbour !== -1)
|
|
531
|
+
entry.group = neighbour;
|
|
532
|
+
if (entry.group !== -1)
|
|
533
|
+
neighbour = entry.group;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
477
536
|
const rewrite = fixer => {
|
|
478
537
|
// Stable, so imports that share a group keep the order they were written in.
|
|
479
|
-
const sorted = [...
|
|
538
|
+
const sorted = [...region].sort((a, b) => a.group - b.group);
|
|
480
539
|
let text = '';
|
|
481
540
|
let previous;
|
|
482
541
|
for (const entry of sorted) {
|
|
@@ -487,10 +546,12 @@ const rule = {
|
|
|
487
546
|
text += tightened(entry).text;
|
|
488
547
|
previous = entry;
|
|
489
548
|
}
|
|
490
|
-
return fixer.replaceTextRange([first.start,
|
|
549
|
+
return fixer.replaceTextRange([first.start, regionLast.end], text);
|
|
491
550
|
};
|
|
492
551
|
let previousGroup = -1;
|
|
493
552
|
for (const entry of entries) {
|
|
553
|
+
if (!entry.isImport)
|
|
554
|
+
continue;
|
|
494
555
|
if (entry.group < previousGroup) {
|
|
495
556
|
context.report({
|
|
496
557
|
node: entry.node,
|
|
@@ -502,14 +563,65 @@ const rule = {
|
|
|
502
563
|
}
|
|
503
564
|
previousGroup = entry.group;
|
|
504
565
|
}
|
|
505
|
-
|
|
506
|
-
|
|
566
|
+
// Blank lines split the region into paragraphs, and an export belongs to its paragraph's block.
|
|
567
|
+
const paragraphs = [];
|
|
568
|
+
for (const [i, entry] of region.entries()) {
|
|
569
|
+
const above = region[i - 1];
|
|
570
|
+
paragraphs.push(above ? (paragraphs[i - 1] ?? 0) + (blankBetween(above, entry) > 0 ? 1 : 0) : 0);
|
|
571
|
+
}
|
|
572
|
+
let misplaced = false;
|
|
573
|
+
for (const [i, entry] of region.entries()) {
|
|
574
|
+
if (!sited.has(entry))
|
|
575
|
+
continue;
|
|
576
|
+
const internal = isInternalGroup(entry.group);
|
|
577
|
+
const nearest = (step, sameParagraph) => {
|
|
578
|
+
for (let j = i + step; j >= 0 && j < region.length; j += step) {
|
|
579
|
+
const other = region[j];
|
|
580
|
+
if (other?.isImport && (!sameParagraph || paragraphs[j] === paragraphs[i]))
|
|
581
|
+
return other;
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
};
|
|
585
|
+
const beside = nearest(-1, true) ?? nearest(1, true);
|
|
586
|
+
const above = nearest(-1, false);
|
|
587
|
+
const below = nearest(1, false);
|
|
588
|
+
// A paragraph of exports alone sits between blocks, and may belong to either; the top is external.
|
|
589
|
+
const fits = beside
|
|
590
|
+
? isInternalGroup(beside.group) === internal
|
|
591
|
+
: (above !== undefined && isInternalGroup(above.group)) === internal
|
|
592
|
+
|| (below !== undefined && isInternalGroup(below.group) === internal);
|
|
593
|
+
if (fits)
|
|
594
|
+
continue;
|
|
595
|
+
misplaced = true;
|
|
596
|
+
context.report({
|
|
597
|
+
node: entry.node,
|
|
598
|
+
messageId: 'exportBlock',
|
|
599
|
+
data: { origin: internal ? 'internal' : 'external' },
|
|
600
|
+
fix: rewrite,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
if (misplaced)
|
|
604
|
+
return;
|
|
605
|
+
let previousIndex = entries.findIndex(entry => entry.isImport);
|
|
606
|
+
for (let i = previousIndex + 1; i < entries.length; i++) {
|
|
607
|
+
const previous = entries[previousIndex];
|
|
507
608
|
const entry = entries[i];
|
|
508
|
-
if (!previous || !entry)
|
|
609
|
+
if (!previous || !entry?.isImport)
|
|
509
610
|
continue;
|
|
510
|
-
const
|
|
511
|
-
|
|
611
|
+
const gaps = [];
|
|
612
|
+
for (let k = previousIndex + 1; k <= i; k++) {
|
|
613
|
+
const above = entries[k - 1];
|
|
614
|
+
const here = entries[k];
|
|
615
|
+
if (above && here)
|
|
616
|
+
gaps.push(blankBetween(above, here));
|
|
617
|
+
}
|
|
618
|
+
const acrossExports = i - previousIndex > 1;
|
|
619
|
+
previousIndex = i;
|
|
512
620
|
const boundary = isInternalGroup(entry.group) !== isInternalGroup(previous.group);
|
|
621
|
+
// Around exports the gaps are free, but the separation still has to fall somewhere in them.
|
|
622
|
+
if (acrossExports && !boundary)
|
|
623
|
+
continue;
|
|
624
|
+
const blank = Math.max(...gaps);
|
|
513
625
|
const wanted = boundary ? blankLinesBetween : 0;
|
|
514
626
|
const below = tightened(entry).blank;
|
|
515
627
|
if (below > 0) {
|
|
@@ -547,15 +659,16 @@ const rule = {
|
|
|
547
659
|
'Program:exit'(program) {
|
|
548
660
|
let chunk = [];
|
|
549
661
|
const flush = () => {
|
|
550
|
-
|
|
551
|
-
|
|
662
|
+
// Exports alone are not an imports block.
|
|
663
|
+
if (chunk.some(node => node.type === 'ImportDeclaration'))
|
|
664
|
+
checkChunk(chunk, chunk[0] === program.body[0]);
|
|
552
665
|
chunk = [];
|
|
553
666
|
};
|
|
554
667
|
for (const statement of program.body) {
|
|
555
668
|
// A bare `import 'x'` sorts like any other — where its side effect has to run in a
|
|
556
669
|
// particular place, that is what a disable comment is for. Anything that is not an
|
|
557
|
-
// import does end the run, since moving code across it could change what happens.
|
|
558
|
-
if (statement.type === 'ImportDeclaration')
|
|
670
|
+
// import or re-export does end the run, since moving code across it could change what happens.
|
|
671
|
+
if (statement.type === 'ImportDeclaration' || isHeaderExport(statement))
|
|
559
672
|
chunk.push(statement);
|
|
560
673
|
else
|
|
561
674
|
flush();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-imports-regulation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Order imports: external type-only, external, blank line, internal type-only, internal — with type specifiers last inside each import.",
|
|
5
5
|
"author": "Robert Sandiford",
|
|
6
6
|
"type": "module",
|