eslint-plugin-imports-regulation 0.2.16 → 0.2.17

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' | 'codeAmongImports' | 'manageLineLength' | 'exportsBlockGap' | 'exportsBlockSpacing';
2
+ type MessageIds = 'groupOrder' | 'exportBlock' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'blankLineBelowComment' | 'typeSpecifiersLast' | 'typeSpecifiersFirst' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter' | 'multilineBraces' | 'codeAmongImports' | 'namesFitOneLine' | 'nameLineTooLong' | 'nameFitsAbove' | 'exportsBlockGap' | 'exportsBlockSpacing';
3
3
  type Origin = 'external' | 'internal';
4
4
  type Options = [
5
5
  {
@@ -82,7 +82,9 @@ const rule = {
82
82
  typeOnlySpecifier: '`{{name}}` is only used as a type — mark it `type`.',
83
83
  blankLinesAfter: 'Expected {{expected}} blank {{lines}} after the imports, found {{actual}}.',
84
84
  codeAmongImports: 'Expected no code among the imports.',
85
- manageLineLength: 'Expected these names to fill the line, within {{maxLen}} characters.',
85
+ namesFitOneLine: 'This import fits on one line.',
86
+ nameLineTooLong: 'This name runs the line past {{maxLen}} characters.',
87
+ nameFitsAbove: 'This name fits on the line above.',
86
88
  exportsBlockGap: 'Expected at most {{expected}} blank {{lines}} before the exports block, found {{actual}}.',
87
89
  exportsBlockSpacing: 'Expected at most {{expected}} blank {{lines}} between the exports, found {{actual}}.',
88
90
  multilineBraces: 'Expected a multiline {{kind}} to end a line with `{` and start one with `}{{tail}}`.',
@@ -219,14 +221,23 @@ const rule = {
219
221
  const interior = source.text.slice(range[0], range[1]);
220
222
  const { carried, tail } = commentsInBraces(named, range);
221
223
  const pieceOf = (specifier) => carried.get(specifier) ?? { leading: [], beforeComma: [], afterComma: [] };
222
- if (!BREAK.test(interior)) {
224
+ // The names' own lines, not the braces': names on one line stay on one line, whether or
225
+ // not the braces sit on lines of their own.
226
+ const first = named[0];
227
+ const last = named[named.length - 1];
228
+ const oneLine = first !== undefined && last !== undefined && first.loc.start.line === last.loc.end.line;
229
+ if (oneLine) {
223
230
  // Only block comments fit on one line, so a trailing one can always sit before the comma.
224
- const pad = /^\s/.test(interior) ? ' ' : '';
231
+ const pad = BREAK.test(interior) ? '' : /^\s/.test(interior) ? ' ' : '';
225
232
  const parts = ordered.map(specifier => {
226
233
  const { leading, beforeComma, afterComma } = pieceOf(specifier);
227
234
  return [...leading, textOf(specifier), ...beforeComma, ...afterComma].join(' ');
228
235
  });
229
- return `${pad}${parts.join(', ')}${tail.map(comment => ` ${comment}`).join('')}${pad}`;
236
+ const names = `${parts.join(', ')}${tail.map(comment => ` ${comment}`).join('')}`;
237
+ if (!BREAK.test(interior))
238
+ return `${pad}${names}${pad}`;
239
+ const { indent, closeIndent, trailingComma } = shapeOf(range, named);
240
+ return `${eol}${indent}${names}${trailingComma ? ',' : ''}${eol}${closeIndent}`;
230
241
  }
231
242
  const { indent, closeIndent, trailingComma } = shapeOf(range, named);
232
243
  const lines = [];
@@ -409,56 +420,138 @@ const rule = {
409
420
  /** Whitespace runs closed up, so a statement spread over lines can be measured as one. */
410
421
  const flattened = (text) => text.replace(/\s*(?:\r\n|\n|\r)\s*/g, ' ');
411
422
  /**
412
- * The names filled to `maxLen`: one line if the whole statement fits, else stacked.
423
+ * The line length, as three faults with a fix each: a statement spread over lines that would
424
+ * fit on one, a line of names past the limit, and a name that would fit on the line above.
413
425
  *
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.
426
+ * Each fix moves one break, leaving everything else as written — the order, the trailing
427
+ * comma and the spacing are other checks' business.
416
428
  */
417
- const checkPacking = (node, specifiers, open, close) => {
429
+ const checkLineLength = (node, specifiers, open, close) => {
418
430
  if (specifiers.length === 0)
419
431
  return false;
420
432
  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.
433
+ // A comment among the names has a line of its own to be on, and moving names across it
434
+ // would change what it describes; attributes bring braces that flattening would mangle.
423
435
  if (source.getAllComments().some(c => c.range[0] >= interior[0] && c.range[1] <= interior[1]))
424
436
  return false;
425
437
  if (node.type === 'ImportDeclaration' && node.attributes.length > 0)
426
438
  return false;
439
+ const written = source.getText(node);
427
440
  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))
441
+ // On one line a trailing comma is a dangling one, so it goes with the break it belonged to.
442
+ const flat = flattened(written).replace(/,(\s*})/, '$1');
443
+ if (BREAK.test(written) && base.length + flat.length <= maxLen) {
444
+ context.report({
445
+ node,
446
+ messageId: 'namesFitOneLine',
447
+ fix: fixer => fixer.replaceTextRange(node.range, flat),
448
+ });
449
+ return true;
450
+ }
451
+ // The names as they are laid out, a run to each line.
452
+ const rows = [];
453
+ for (const specifier of specifiers) {
454
+ const row = rows[rows.length - 1];
455
+ if (row?.[row.length - 1]?.loc.end.line === specifier.loc.start.line)
456
+ row.push(specifier);
457
+ else
458
+ rows.push([specifier]);
459
+ }
460
+ /**
461
+ * The names rewritten to fill lines from `column`, breaking to `indent` where they must.
462
+ *
463
+ * `tail` is what follows the last name on its line — a comma, the closing brace, the
464
+ * module — which is part of that line's length even though it is not a name.
465
+ */
466
+ const rewrapped = (names, column, indent, tail) => {
467
+ let text = '';
468
+ let width = column;
469
+ for (const [i, specifier] of names.entries()) {
470
+ const name = source.getText(specifier);
471
+ const last = i === names.length - 1;
472
+ // A comma follows every name but the last, which carries the tail instead.
473
+ const owed = last ? tail : 1;
474
+ if (i === 0) {
475
+ text = name;
476
+ width += name.length;
477
+ }
478
+ else if (width + 2 + name.length + owed <= maxLen) {
479
+ text += `, ${name}`;
480
+ width += 2 + name.length;
481
+ }
482
+ else {
483
+ text += `,${eol}${indent}${name}`;
484
+ width = indent.length + name.length;
485
+ }
486
+ }
487
+ return text;
488
+ };
489
+ const indentOf = (line) => line === node.loc.start.line ? `${base} ` : /^[^\S\r\n]*/.exec(lineAt(line - 1))?.[0] ?? `${base} `;
490
+ const spanOf = (names) => [names[0].range[0], names[names.length - 1].range[1]];
491
+ // To the end of the statement, not the end of the line: a comment beside an import is not
492
+ // the import's length, and breaking the names would not shorten it.
493
+ const widthAt = (line) => line === node.loc.end.line ? node.loc.end.column : lineAt(line - 1).trimEnd().length;
494
+ /** What sits after these names on their last line, and so shares it. */
495
+ const tailOf = (names) => {
496
+ const last = names[names.length - 1];
497
+ return Math.max(widthAt(last.loc.end.line) - last.loc.end.column, 0);
498
+ };
499
+ const report = (names, on, messageId) => {
500
+ const span = spanOf(names);
501
+ const text = rewrapped(names, names[0].loc.start.column, indentOf(names[0].loc.start.line), tailOf(names));
502
+ if (text === source.text.slice(...span))
450
503
  return false;
504
+ context.report({
505
+ node: on,
506
+ messageId,
507
+ data: { maxLen: String(maxLen) },
508
+ fix: fixer => fixer.replaceTextRange(span, text),
509
+ });
510
+ return true;
511
+ };
512
+ // A line over the limit with one name on it is over because of what surrounds the name, so
513
+ // the braces are what has to give: the names take lines of their own.
514
+ const overLong = rows.find(row => row[0] && widthAt(row[0].loc.start.line) > maxLen);
515
+ if (overLong && !BREAK.test(written)) {
516
+ const indent = `${base} `;
517
+ const prefix = flattened(source.text.slice(node.range[0], open.range[1]));
518
+ const suffix = flattened(source.text.slice(close.range[0], node.range[1]));
519
+ const names = rewrapped(specifiers, indent.length, indent, 0);
520
+ context.report({
521
+ node,
522
+ messageId: 'nameLineTooLong',
523
+ data: { maxLen: String(maxLen) },
524
+ fix: fixer => fixer.replaceTextRange(node.range, `${prefix}${eol}${indent}${names}${eol}${base}${suffix}`),
525
+ });
526
+ return true;
451
527
  }
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;
528
+ // Names of one kind to a line, so a value never joins a line of types, or the other way.
529
+ // A row holding both is the order check's to sort out, and stands alone here.
530
+ const kindOf = (row) => {
531
+ const types = row.filter(isTypeSpecifier).length;
532
+ return types === 0 ? 'value' : types === row.length ? 'type' : 'mixed';
533
+ };
534
+ const runs = [];
535
+ for (const row of rows) {
536
+ const run = runs[runs.length - 1];
537
+ const previous = run?.[run.length - 1];
538
+ if (run && previous && kindOf(previous) === kindOf(row) && kindOf(row) !== 'mixed')
539
+ run.push(row);
540
+ else
541
+ runs.push([row]);
542
+ }
543
+ // A run at a time, since one line's names spilling down changes what the next line can hold;
544
+ // fixing a pair at a time would need a pass for every line.
545
+ for (const run of runs) {
546
+ const names = run.flat();
547
+ const long = run.some(row => widthAt(row[0].loc.start.line) > maxLen);
548
+ const on = long
549
+ ? names.find(specifier => specifier.loc.end.column > maxLen) ?? names[1] ?? names[0]
550
+ : (run[1]?.[0] ?? names[0]);
551
+ if (report(names, on, long ? 'nameLineTooLong' : 'nameFitsAbove'))
552
+ return true;
553
+ }
554
+ return false;
462
555
  };
463
556
  /** Whether it reported the layout, which leaves the order and shape checks nothing to say. */
464
557
  const checkBraces = (node, specifiers) => {
@@ -470,7 +563,7 @@ const rule = {
470
563
  const close = source.getTokenAfter(last, { filter: token => token.value === '}' });
471
564
  if (!open || !close)
472
565
  return false;
473
- if (manageLineLength && checkPacking(node, specifiers, open, close))
566
+ if (manageLineLength && checkLineLength(node, specifiers, open, close))
474
567
  return true;
475
568
  // To the module, not the statement's end, so a multiline `with { … }` is not counted.
476
569
  const end = node.source ?? close;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-imports-regulation",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
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",