eslint-plugin-imports-regulation 0.2.14 → 0.2.16
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' | 'exportBlock' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'blankLineBelowComment' | 'typeSpecifiersLast' | 'typeSpecifiersFirst' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter' | 'multilineBraces';
|
|
2
|
+
type MessageIds = 'groupOrder' | 'exportBlock' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'blankLineBelowComment' | 'typeSpecifiersLast' | 'typeSpecifiersFirst' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter' | 'multilineBraces' | 'codeAmongImports' | 'manageLineLength' | 'exportsBlockGap' | 'exportsBlockSpacing';
|
|
3
3
|
type Origin = 'external' | 'internal';
|
|
4
4
|
type Options = [
|
|
5
5
|
{
|
|
@@ -14,6 +14,7 @@ type Options = [
|
|
|
14
14
|
preferTypeDeclarations?: boolean;
|
|
15
15
|
inferTypeOnlyImports?: boolean;
|
|
16
16
|
maxLen?: number;
|
|
17
|
+
manageLineLength?: boolean;
|
|
17
18
|
}?
|
|
18
19
|
];
|
|
19
20
|
declare const rule: TSESLint.RuleModule<MessageIds, Options>;
|
|
@@ -62,6 +62,9 @@ const rule = {
|
|
|
62
62
|
inferTypeOnlyImports: { type: 'boolean' },
|
|
63
63
|
// The longest line a fix may write when it gathers names; lines as written are left alone.
|
|
64
64
|
maxLen: { type: 'integer', minimum: 1 },
|
|
65
|
+
// Reformat the names to fill each line up to `maxLen`, breaking up longer lines.
|
|
66
|
+
// Off, and the lines are left as written.
|
|
67
|
+
manageLineLength: { type: 'boolean' },
|
|
65
68
|
},
|
|
66
69
|
additionalProperties: false,
|
|
67
70
|
},
|
|
@@ -78,6 +81,10 @@ const rule = {
|
|
|
78
81
|
typeOnlyImport: 'Everything this imports is only used as a type — write `import type`.',
|
|
79
82
|
typeOnlySpecifier: '`{{name}}` is only used as a type — mark it `type`.',
|
|
80
83
|
blankLinesAfter: 'Expected {{expected}} blank {{lines}} after the imports, found {{actual}}.',
|
|
84
|
+
codeAmongImports: 'Expected no code among the imports.',
|
|
85
|
+
manageLineLength: 'Expected these names to fill the line, within {{maxLen}} characters.',
|
|
86
|
+
exportsBlockGap: 'Expected at most {{expected}} blank {{lines}} before the exports block, found {{actual}}.',
|
|
87
|
+
exportsBlockSpacing: 'Expected at most {{expected}} blank {{lines}} between the exports, found {{actual}}.',
|
|
81
88
|
multilineBraces: 'Expected a multiline {{kind}} to end a line with `{` and start one with `}{{tail}}`.',
|
|
82
89
|
},
|
|
83
90
|
},
|
|
@@ -95,6 +102,7 @@ const rule = {
|
|
|
95
102
|
const preferTypeDeclarations = options?.preferTypeDeclarations ?? true;
|
|
96
103
|
const inferTypeOnlyImports = options?.inferTypeOnlyImports ?? true;
|
|
97
104
|
const maxLen = options?.maxLen ?? DEFAULT_MAX_LEN;
|
|
105
|
+
const manageLineLength = options?.manageLineLength ?? true;
|
|
98
106
|
const eol = BREAK.exec(source.text)?.[0] ?? '\n';
|
|
99
107
|
const originOf = (path) => {
|
|
100
108
|
for (const { test, group } of patterns)
|
|
@@ -398,30 +406,87 @@ const rule = {
|
|
|
398
406
|
const values = specifiers.filter(specifier => !isTypeSpecifier(specifier));
|
|
399
407
|
return [...pack(types, indent, values.length > 0 || trailingComma), ...pack(values, indent, trailingComma)];
|
|
400
408
|
};
|
|
409
|
+
/** Whitespace runs closed up, so a statement spread over lines can be measured as one. */
|
|
410
|
+
const flattened = (text) => text.replace(/\s*(?:\r\n|\n|\r)\s*/g, ' ');
|
|
411
|
+
/**
|
|
412
|
+
* The names filled to `maxLen`: one line if the whole statement fits, else stacked.
|
|
413
|
+
*
|
|
414
|
+
* The whole statement, not the names alone, since what is owed is a line within the limit —
|
|
415
|
+
* and what sits outside the braces (`import type {`, `} from '…'`) is part of that line.
|
|
416
|
+
*/
|
|
417
|
+
const checkPacking = (node, specifiers, open, close) => {
|
|
418
|
+
if (specifiers.length === 0)
|
|
419
|
+
return false;
|
|
420
|
+
const interior = [open.range[1], close.range[0]];
|
|
421
|
+
// Rebuilt from the specifiers alone, so a comment among them would be dropped; attributes
|
|
422
|
+
// bring braces of their own, which flattening would mangle.
|
|
423
|
+
if (source.getAllComments().some(c => c.range[0] >= interior[0] && c.range[1] <= interior[1]))
|
|
424
|
+
return false;
|
|
425
|
+
if (node.type === 'ImportDeclaration' && node.attributes.length > 0)
|
|
426
|
+
return false;
|
|
427
|
+
const base = /^[^\S\r\n]*/.exec(lineAt(node.loc.start.line - 1))?.[0] ?? '';
|
|
428
|
+
const prefix = flattened(source.text.slice(node.range[0], open.range[1]));
|
|
429
|
+
const suffix = flattened(source.text.slice(close.range[0], node.range[1]));
|
|
430
|
+
const ordered = [
|
|
431
|
+
...specifiers.filter(specifier => !isTypeSpecifier(specifier)),
|
|
432
|
+
...specifiers.filter(isTypeSpecifier),
|
|
433
|
+
];
|
|
434
|
+
const names = ordered.map(specifier => source.getText(specifier)).join(', ');
|
|
435
|
+
const oneLine = `${base}${prefix} ${names} ${suffix}`;
|
|
436
|
+
let wanted;
|
|
437
|
+
if (oneLine.length <= maxLen)
|
|
438
|
+
wanted = oneLine;
|
|
439
|
+
else {
|
|
440
|
+
const { indent } = source.text.slice(...interior).includes(eol)
|
|
441
|
+
? shapeOf(interior, specifiers)
|
|
442
|
+
: { indent: `${base} ` };
|
|
443
|
+
const lines = stacked(specifiers, indent, true);
|
|
444
|
+
wanted = `${base}${prefix}${eol}${lines.join(eol)}${eol}${base}${suffix}`;
|
|
445
|
+
// Breaking up a single line that is over the limit whatever is done to it gains nothing,
|
|
446
|
+
// and would be reported for ever. Filling lines makes them longer, which is the point.
|
|
447
|
+
const longest = (text) => Math.max(...text.split(BREAK_GLOBAL).map(line => line.length));
|
|
448
|
+
const written = `${base}${source.getText(node)}`;
|
|
449
|
+
if (!BREAK.test(written) && longest(wanted) >= longest(written))
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
const current = `${base}${source.getText(node)}`;
|
|
453
|
+
if (wanted === current)
|
|
454
|
+
return false;
|
|
455
|
+
context.report({
|
|
456
|
+
node,
|
|
457
|
+
messageId: 'manageLineLength',
|
|
458
|
+
data: { maxLen: String(maxLen) },
|
|
459
|
+
fix: fixer => fixer.replaceTextRange(node.range, wanted.slice(base.length)),
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
};
|
|
463
|
+
/** Whether it reported the layout, which leaves the order and shape checks nothing to say. */
|
|
401
464
|
const checkBraces = (node, specifiers) => {
|
|
402
465
|
const first = specifiers[0];
|
|
403
466
|
const last = specifiers[specifiers.length - 1];
|
|
404
467
|
if (!first || !last)
|
|
405
|
-
return;
|
|
468
|
+
return false;
|
|
406
469
|
const open = source.getTokenBefore(first, { filter: token => token.value === '{' });
|
|
407
470
|
const close = source.getTokenAfter(last, { filter: token => token.value === '}' });
|
|
408
471
|
if (!open || !close)
|
|
409
|
-
return;
|
|
472
|
+
return false;
|
|
473
|
+
if (manageLineLength && checkPacking(node, specifiers, open, close))
|
|
474
|
+
return true;
|
|
410
475
|
// To the module, not the statement's end, so a multiline `with { … }` is not counted.
|
|
411
476
|
const end = node.source ?? close;
|
|
412
477
|
if (node.loc.start.line === end.loc.end.line)
|
|
413
|
-
return;
|
|
478
|
+
return false;
|
|
414
479
|
const afterOpen = source.getTokenAfter(open, { includeComments: true });
|
|
415
480
|
const beforeClose = source.getTokenBefore(close, { includeComments: true });
|
|
416
481
|
const afterClose = source.getTokenAfter(close, { includeComments: true });
|
|
417
482
|
if (!afterOpen || !beforeClose)
|
|
418
|
-
return;
|
|
483
|
+
return false;
|
|
419
484
|
const openOk = afterOpen.loc.start.line > open.loc.end.line || afterOpen.type === 'Line';
|
|
420
485
|
const closeOk = beforeClose.loc.end.line < close.loc.start.line;
|
|
421
486
|
const from = node.source && afterClose?.value === 'from' ? afterClose : undefined;
|
|
422
487
|
const fromOk = !from || from.loc.start.line === close.loc.end.line;
|
|
423
488
|
if (openOk && closeOk && fromOk)
|
|
424
|
-
return;
|
|
489
|
+
return false;
|
|
425
490
|
const base = /^[^\S\r\n]*/.exec(lineAt(node.loc.start.line - 1))?.[0] ?? '';
|
|
426
491
|
let indent = `${base} `;
|
|
427
492
|
for (let line = node.loc.start.line; line < end.loc.end.line; line++) {
|
|
@@ -446,7 +511,22 @@ const rule = {
|
|
|
446
511
|
return fixes;
|
|
447
512
|
},
|
|
448
513
|
});
|
|
514
|
+
return true;
|
|
449
515
|
};
|
|
516
|
+
// Matched by its name after the plugin prefix, which differs by config.
|
|
517
|
+
const ruleName = context.id.split('/').pop() ?? context.id;
|
|
518
|
+
/** This rule is disabled on the statement's last line. */
|
|
519
|
+
const passedOver = (statement) => source.getCommentsAfter(statement).some(comment => {
|
|
520
|
+
if (comment.loc.start.line !== statement.loc.end.line)
|
|
521
|
+
return false;
|
|
522
|
+
const directive = /^eslint-disable-line(?:\s+([^]*?))?(?:\s--[^]*)?$/.exec(comment.value.trim());
|
|
523
|
+
if (!directive)
|
|
524
|
+
return false;
|
|
525
|
+
const rules = directive[1]?.split(',').map(rule => rule.trim()).filter(Boolean) ?? [];
|
|
526
|
+
return rules.length === 0 || rules.some(rule => rule === ruleName || rule.endsWith(`/${ruleName}`));
|
|
527
|
+
});
|
|
528
|
+
// Passed-over statements, by the header statement they follow and travel with.
|
|
529
|
+
const attached = new Map();
|
|
450
530
|
/** `export … from` and `export { … }`, which sit among the imports; a declaration is code. */
|
|
451
531
|
const isHeaderExport = (node) => node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.declaration === null);
|
|
452
532
|
/** Comments that mean something only where they are, so they never move with an import. */
|
|
@@ -494,9 +574,10 @@ const rule = {
|
|
|
494
574
|
}
|
|
495
575
|
// Every comment on the import's last line, not just the first: a second one there is
|
|
496
576
|
// not a note on the next import.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
577
|
+
const tail = attached.get(node) ?? node;
|
|
578
|
+
let end = tail.range[1];
|
|
579
|
+
for (const comment of source.getCommentsAfter(tail)) {
|
|
580
|
+
if (comment.loc.start.line !== tail.loc.end.line)
|
|
500
581
|
break;
|
|
501
582
|
end = comment.range[1];
|
|
502
583
|
}
|
|
@@ -624,6 +705,30 @@ const rule = {
|
|
|
624
705
|
}
|
|
625
706
|
const region = entries.slice(0, tail);
|
|
626
707
|
const regionLast = region[region.length - 1] ?? last;
|
|
708
|
+
// The block is loose about what it holds, but one line separates it, or none at all if it
|
|
709
|
+
// is tucked against the imports.
|
|
710
|
+
const exportsGap = 1;
|
|
711
|
+
const closeGap = (above, here, blank, messageId) => {
|
|
712
|
+
context.report({
|
|
713
|
+
node: here.node,
|
|
714
|
+
messageId,
|
|
715
|
+
data: {
|
|
716
|
+
expected: String(exportsGap),
|
|
717
|
+
actual: String(blank),
|
|
718
|
+
lines: exportsGap === 1 ? 'line' : 'lines',
|
|
719
|
+
},
|
|
720
|
+
fix: fixer => fixer.replaceTextRange([above.end, here.start], eol.repeat(exportsGap + 1)),
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
for (let i = tail; i < entries.length; i++) {
|
|
724
|
+
const above = entries[i - 1];
|
|
725
|
+
const here = entries[i];
|
|
726
|
+
if (!above || !here)
|
|
727
|
+
continue;
|
|
728
|
+
const blank = blankBetween(above, here);
|
|
729
|
+
if (blank > exportsGap)
|
|
730
|
+
closeGap(above, here, blank, i === tail ? 'exportsBlockGap' : 'exportsBlockSpacing');
|
|
731
|
+
}
|
|
627
732
|
// An unpaired export belongs with its module's imports; one with no module stays with its neighbour.
|
|
628
733
|
const sited = new Set();
|
|
629
734
|
for (const entry of region) {
|
|
@@ -765,7 +870,10 @@ const rule = {
|
|
|
765
870
|
};
|
|
766
871
|
return {
|
|
767
872
|
ImportDeclaration(node) {
|
|
768
|
-
|
|
873
|
+
// A repacked import is rewritten whole, so the specifier checks would report the same
|
|
874
|
+
// thing twice; what is left of them lands on the next pass.
|
|
875
|
+
if (checkBraces(node, namedOf(node)))
|
|
876
|
+
return;
|
|
769
877
|
checkSpecifiers(node);
|
|
770
878
|
},
|
|
771
879
|
ExportNamedDeclaration(node) {
|
|
@@ -779,14 +887,32 @@ const rule = {
|
|
|
779
887
|
checkChunk(chunk, chunk[0] === program.body[0]);
|
|
780
888
|
chunk = [];
|
|
781
889
|
};
|
|
782
|
-
|
|
890
|
+
const isHeader = (statement) => statement !== undefined && (statement.type === 'ImportDeclaration' || isHeaderExport(statement));
|
|
891
|
+
const body = program.body;
|
|
892
|
+
for (let i = 0; i < body.length; i++) {
|
|
893
|
+
const statement = body[i];
|
|
783
894
|
// A bare `import 'x'` sorts like any other — where its side effect has to run in a
|
|
784
895
|
// particular place, that is what a disable comment is for. Anything that is not an
|
|
785
896
|
// import or re-export does end the run, since moving code across it could change what happens.
|
|
786
|
-
if (
|
|
897
|
+
if (isHeader(statement)) {
|
|
787
898
|
chunk.push(statement);
|
|
788
|
-
|
|
789
|
-
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
// Unless this rule is disabled on its line and imports carry on after it: then it goes with the import above.
|
|
902
|
+
let j = i;
|
|
903
|
+
for (let next = body[j]; next && !isHeader(next) && passedOver(next); next = body[j])
|
|
904
|
+
j++;
|
|
905
|
+
const above = chunk[chunk.length - 1];
|
|
906
|
+
const last = body[j - 1];
|
|
907
|
+
if (above && last && j > i && isHeader(body[j])) {
|
|
908
|
+
attached.set(above, last);
|
|
909
|
+
// Suppressed by the directive, and what keeps it in use: unused, `--fix` would delete it.
|
|
910
|
+
for (const passed of body.slice(i, j))
|
|
911
|
+
context.report({ node: passed, messageId: 'codeAmongImports' });
|
|
912
|
+
i = j - 1;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
flush();
|
|
790
916
|
}
|
|
791
917
|
flush();
|
|
792
918
|
},
|
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.16",
|
|
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",
|