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
- let end = node.range[1];
498
- for (const comment of source.getCommentsAfter(node)) {
499
- if (comment.loc.start.line !== node.loc.end.line)
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
- checkBraces(node, namedOf(node));
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
- for (const statement of program.body) {
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 (statement.type === 'ImportDeclaration' || isHeaderExport(statement))
897
+ if (isHeader(statement)) {
787
898
  chunk.push(statement);
788
- else
789
- flush();
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.14",
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",