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
- if (variable.references.every(reference => reference.isTypeReference))
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
- entries.push({ node, group: groupOf(node), start, end });
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
- checkAfterImports(last);
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 = [...entries].sort((a, b) => a.group - b.group);
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, last.end], text);
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
- for (let i = 1; i < entries.length; i++) {
506
- const previous = entries[i - 1];
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 gap = source.text.slice(previous.end, entry.start);
511
- const blank = Math.max((gap.match(BREAK_GLOBAL) ?? []).length - 1, 0);
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
- if (chunk.length > 0)
551
- checkChunk(chunk);
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.9",
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",