From 464fa712e4ad0b560cd86be95149e9dde9097458 Mon Sep 17 00:00:00 2001 From: "Xunnamius (Romulus)" Date: Mon, 30 Dec 2024 17:12:49 -0800 Subject: [PATCH] feat: [import/order] collapse excess spacing for aesthetically pleasing imports via `consolidateIslands` --- src/rules/order.js | 39 +- tests/src/rules/order.js | 999 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1031 insertions(+), 7 deletions(-) diff --git a/src/rules/order.js b/src/rules/order.js index c9dafe5de..2c982e571 100644 --- a/src/rules/order.js +++ b/src/rules/order.js @@ -677,7 +677,7 @@ function removeNewLineAfterImport(context, currentImport, previousImport) { return undefined; } -function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, newlinesBetweenTypeOnlyImports, distinctGroup, isSortingTypesGroup) { +function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, newlinesBetweenTypeOnlyImports_, distinctGroup, isSortingTypesGroup, isConsolidatingSpaceBetweenImports) { const getNumberOfEmptyLinesBetween = (currentImport, previousImport) => { const linesBetweenImports = getSourceCode(context).lines.slice( previousImport.node.loc.end.line, @@ -703,12 +703,24 @@ function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, ne const isTypeOnlyImport = currentImport.node.importKind === 'type'; const isPreviousImportTypeOnlyImport = previousImport.node.importKind === 'type'; - const isNormalImportFollowingTypeOnlyImportAndRelevant = - !isTypeOnlyImport && isPreviousImportTypeOnlyImport && isSortingTypesGroup; + const isNormalImportNextToTypeOnlyImportAndRelevant = + isTypeOnlyImport !== isPreviousImportTypeOnlyImport && isSortingTypesGroup; const isTypeOnlyImportAndRelevant = isTypeOnlyImport && isSortingTypesGroup; + // In the special case where newlinesBetweenTypeOnlyImports and + // consolidateIslands want the opposite thing, consolidateIslands wins + const newlinesBetweenTypeOnlyImports = + newlinesBetweenTypeOnlyImports_ === 'never' && + isConsolidatingSpaceBetweenImports && + isSortingTypesGroup && + (isNormalImportNextToTypeOnlyImportAndRelevant || + previousImport.isMultiline || + currentImport.isMultiline) + ? 'always-and-inside-groups' + : newlinesBetweenTypeOnlyImports_; + const isNotIgnored = (isTypeOnlyImportAndRelevant && newlinesBetweenTypeOnlyImports !== 'ignore') || @@ -731,7 +743,7 @@ function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, ne const shouldAssertNoNewlineBetweenGroup = !isSortingTypesGroup || - !isNormalImportFollowingTypeOnlyImportAndRelevant || + !isNormalImportNextToTypeOnlyImportAndRelevant || newlinesBetweenTypeOnlyImports === 'never'; if (shouldAssertNewlineBetweenGroups) { @@ -844,6 +856,12 @@ module.exports = { 'never', ], }, + consolidateIslands: { + enum: [ + 'inside-groups', + 'never', + ], + }, sortTypesGroup: { type: 'boolean', default: false, @@ -906,6 +924,7 @@ module.exports = { const newlinesBetweenTypeOnlyImports = options['newlines-between-types'] || newlinesBetweenImports; const pathGroupsExcludedImportTypes = new Set(options.pathGroupsExcludedImportTypes || ['builtin', 'external', 'object']); const sortTypesGroup = options.sortTypesGroup; + const consolidateIslands = options.consolidateIslands || 'never'; const named = { types: 'mixed', @@ -1168,7 +1187,17 @@ module.exports = { 'Program:exit'() { importMap.forEach((imported) => { if (newlinesBetweenImports !== 'ignore' || newlinesBetweenTypeOnlyImports !== 'ignore') { - makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, newlinesBetweenTypeOnlyImports, distinctGroup, isSortingTypesGroup); + makeNewlinesBetweenReport( + context, + imported, + newlinesBetweenImports, + newlinesBetweenTypeOnlyImports, + distinctGroup, + isSortingTypesGroup, + consolidateIslands === 'inside-groups' && + (newlinesBetweenImports === 'always-and-inside-groups' || + newlinesBetweenTypeOnlyImports === 'always-and-inside-groups') + ); } if (alphabetize.order !== 'ignore') { diff --git a/tests/src/rules/order.js b/tests/src/rules/order.js index ca50bcf92..305f4ab5f 100644 --- a/tests/src/rules/order.js +++ b/tests/src/rules/order.js @@ -3739,6 +3739,370 @@ context('TypeScript', function () { } ], }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before' + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'never' (default) + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before' + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'never', + }, + ], + }), + // Ensure the rule doesn't choke and die on absolute paths trying to pass NaN around + test({ + code: ` + import fs from 'node:fs'; + + import '@scoped/package'; + import type { B } from 'node:fs'; + + import type { A1 } from '/bad/bad/bad/bad'; + import './a/b/c'; + import type { A2 } from '/bad/bad/bad/bad'; + import type { A3 } from '/bad/bad/bad/bad'; + import type { D1 } from '/bad/bad/not/good'; + import type { D2 } from '/bad/bad/not/good'; + import type { D3 } from '/bad/bad/not/good'; + + import type { C } from '@something/else'; + + import type { E } from './index.js'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['builtin', 'type', 'unknown', 'external'], + sortTypesGroup: true, + 'newlines-between': 'always' + }, + ], + }), + // Ensure consolidateOptions: 'inside-groups', newlines-between: 'always-and-inside-groups', and newlines-between-types: 'never' do not fight for dominance + test({ + code: ` + import makeVanillaYargs from 'yargs/yargs'; + + import { createDebugLogger } from 'multiverse+rejoinder'; + + import { globalDebuggerNamespace } from 'rootverse+bfe:src/constant.ts'; + import { ErrorMessage, type KeyValueEntry } from 'rootverse+bfe:src/error.ts'; + + import { + $artificiallyInvoked, + $canonical, + $exists, + $genesis + } from 'rootverse+bfe:src/symbols.ts'; + + import type { + Entries, + LiteralUnion, + OmitIndexSignature, + Promisable, + StringKeyOf + } from 'type-fest'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true + }, + named: { + enabled: true, + types: 'types-last' + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'] + ], + pathGroups: [ + { + pattern: 'multiverse{*,*/**}', + group: 'external', + position: 'after' + }, + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after' + }, + { + pattern: 'universe{*,*/**}', + group: 'external', + position: 'after' + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups' + }, + ], + }), + test({ + code: ` + import assert from 'node:assert'; + import { isNativeError } from 'node:util/types'; + + import { runNoRejectOnBadExit } from '@-xun/run'; + import { TrialError } from 'named-app-errors'; + import { resolve as resolverLibrary } from 'resolve.exports'; + + import { toAbsolutePath, type AbsolutePath } from 'rootverse+project-utils:src/fs.ts'; + + import type { PackageJson } from 'type-fest'; + // Some comment about remembering to do something + import type { XPackageJson } from 'rootverse:src/assets/config/_package.json.ts'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true + }, + named: { + enabled: true, + types: 'types-last' + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'] + ], + pathGroups: [ + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after' + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups' + }, + ], + }), ), invalid: [].concat( // Option alphabetize: {order: 'asc'} @@ -3992,7 +4356,6 @@ context('TypeScript', function () { message: '`A` export should occur before export of `B`', }], }), - // Options: sortTypesGroup + newlines-between-types example #1 from the documentation (fail) test({ code: ` @@ -4053,7 +4416,6 @@ context('TypeScript', function () { }, ], }), - // Options: sortTypesGroup + newlines-between-types example #2 from the documentation (fail) test({ code: ` @@ -4103,6 +4465,639 @@ context('TypeScript', function () { }, ], }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' with all newlines + test({ + code: ` + import c from 'Bar'; + + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before' + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: + 'There should be no empty lines between this singleline import and the singleline import that follows it', + line: 2 + }, + { + message: + 'There should be no empty lines between this singleline import and the singleline import that follows it', + line: 60 + }, + { + message: + 'There should be no empty lines between this singleline import and the singleline import that follows it', + line: 76 + } + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' with no newlines + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before' + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 3 + }, + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 4, + }, + { + message: 'There should be at least one empty line between this multiline import and the import that follows it', + line: 13, + }, + { + message: 'There should be at least one empty line between import groups', + line: 22, + }, + { + message: 'There should be at least one empty line between import groups', + line: 23 + }, + { + message: 'There should be at least one empty line between import groups', + line: 24, + }, + { + message: 'There should be at least one empty line between this multiline import and the import that follows it', + line: 25, + }, + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 27, + }, + { + message: 'There should be at least one empty line between import groups', + line: 28, + }, + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 32, + }, + { + message: 'There should be at least one empty line between this multiline import and the import that follows it', + line: 33, + }, + { + message: 'There should be at least one empty line between import groups', + line: 38, + }, + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 39, + }, + { + message: 'There should be at least one empty line between this multiline import and the import that follows it', + line: 40, + }, + { + message: 'There should be at least one empty line between import groups', + line: 46, + }, + { + message: 'There should be at least one empty line between this import and the multiline import that follows it', + line: 47, + }, + { + message: 'There should be at least one empty line between this multiline import and the import that follows it', + line: 48, + }, + { + message: 'There should be at least one empty line between import groups', + line: 53, + }, + { + message: 'There should be at least one empty line between import groups', + line: 54, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'never' (default) + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before' + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'never', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 22, + }, + { + message: 'There should be at least one empty line between import groups', + line: 23 + }, + { + message: 'There should be at least one empty line between import groups', + line: 24, + }, + { + message: 'There should be at least one empty line between import groups', + line: 28, + }, + { + message: 'There should be at least one empty line between import groups', + line: 38, + }, + { + message: 'There should be at least one empty line between import groups', + line: 46, + }, + { + message: 'There should be at least one empty line between import groups', + line: 53, + }, + { + message: 'There should be at least one empty line between import groups', + line: 54, + }, + + ], + }), supportsExportTypeSpecifiers ? [ test({