diff --git a/README.md b/README.md index 2490897..f9362ef 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,15 @@ import g from "."; import h from "./constants"; import i from "./styles"; +// TypeScript import assignments. +import J = require("../parent"); +import K = require("./sibling"); +export import L = require("an-npm-package"); +import M = require("different-npm-package"); +import N = Namespace; +export import O = Namespace.A.B.C; +import P = Namespace.A.C; + // Different types of exports: export { a } from "../.."; export { b } from "/"; @@ -365,6 +374,8 @@ Side effect imports have `\u0000` _prepended_ to their `from` string (starts wit Type imports have `\u0000` _appended_ to their `from` string (ends with `\u0000`). You can match them with `"\\u0000$"` – but you probably need more than that to avoid them also being matched by other regexes. +TypeScript import assignments have `\u0001` (for `import A = require("A")`) or `\u0002` (for `import A = B.C.D`) prepended to their `from` string (starts with `\u0001` or `\u0002`). It is _not_ possible to distinguish `export import A =` and `import A =`. + All imports that match the same regex are sorted internally as mentioned in [Sort order]. This is the default value for the `groups` option: @@ -384,6 +395,8 @@ This is the default value for the `groups` option: // Relative imports. // Anything that starts with a dot. ["^\\."], + // TypeScript import assignments. + ["^\\u0001", "^\\u0002"], ]; ``` @@ -502,6 +515,8 @@ The final whitespace rule is that this plugin puts one import/export per line. I No. This is intentional to keep things simple. Use some other sorting rule, such as [import/order], for sorting `require`. Or consider migrating your code using `require` to `import`. `import` is well supported these days. +The only `require`-like thing supported is TypeScript import assignments like `import Thing = require("something")`. They’re much easier to support since they are very restricted: The thing to the left of the `=` has to be a single identifier, and inside `require()` there has to be a single string literal. This makes it sortable as if it was `import Thing from "something"`. + ### Why sort on `from`? Some other import sorting rules sort based on the first name after `import`, rather than the string after `from`. This plugin intentionally sorts on the `from` string to be `git diff` friendly. @@ -677,7 +692,7 @@ Use [custom grouping], setting the `groups` option to only have a single inner a For example, here’s the default value but changed to a single inner array: ```js -[["^\\u0000", "^node:", "^@?\\w", "^", "^\\."]]; +[["^\\u0000", "^node:", "^@?\\w", "^", "^\\.", "^\\u0001", "^\\u0002"]]; ``` (By default, each string is in its _own_ array (that’s 5 inner arrays) – causing a blank line between each.) diff --git a/examples/.eslintrc.js b/examples/.eslintrc.js index 44df29b..8ffd10f 100644 --- a/examples/.eslintrc.js +++ b/examples/.eslintrc.js @@ -103,7 +103,7 @@ module.exports = { "error", { // The default grouping, but with no blank lines. - groups: [["^\\u0000", "^node:", "^@?\\w", "^", "^\\."]], + groups: [["^\\u0000", "^node:", "^@?\\w", "^", "^\\.", "^\\u0001", "^\\u0002"]], }, ], }, @@ -115,7 +115,7 @@ module.exports = { "error", { // The default grouping, but in reverse. - groups: [["^\\."], ["^"], ["^@?\\w"], ["^node:"], ["^\\u0000"]], + groups: [["^\\u0001", "^\\u0002"], ["^\\."], ["^"], ["^@?\\w"], ["^node:"], ["^\\u0000"]], }, ], }, @@ -128,7 +128,7 @@ module.exports = { "error", { // The default grouping, but with type imports first as a separate group. - groups: [["^.*\\u0000$"], ["^\\u0000"], ["^node:"], ["^@?\\w"], ["^"], ["^\\."]], + groups: [["^.*\\u0000$"], ["^\\u0000"], ["^node:"], ["^@?\\w"], ["^"], ["^\\."], ["^\\u0001", "^\\u0002"]], }, ], }, @@ -141,7 +141,7 @@ module.exports = { "error", { // The default grouping, but with type imports last as a separate group. - groups: [["^\\u0000"], ["^node:"], ["^@?\\w"], ["^"], ["^\\."], ["^.+\\u0000$"]], + groups: [["^\\u0000"], ["^node:"], ["^@?\\w"], ["^"], ["^\\."], ["^\\u0001", "^\\u0002"], ["^.+\\u0000$"]], }, ], }, @@ -162,6 +162,7 @@ module.exports = { ["^@?\\w"], ["^"], ["^\\."], + ["^\\u0001", "^\\u0002"], ], }, ], @@ -182,6 +183,7 @@ module.exports = { ["^@?\\w"], ["^"], ["^\\."], + ["^\\u0001", "^\\u0002"], ["^node:.*\\u0000$", "^@?\\w.*\\u0000$", "^[^.].*\\u0000$", "^\\..*\\u0000$"], ], }, @@ -202,6 +204,7 @@ module.exports = { ["^@?\\w.*\\u0000$", "^@?\\w"], ["(?<=\\u0000)$", "^"], ["^\\..*\\u0000$", "^\\."], + ["^\\u0001", "^\\u0002"], ], }, ], diff --git a/examples/readme-order.prettier.ts b/examples/readme-order.prettier.ts index f5e9c73..d1b4f28 100644 --- a/examples/readme-order.prettier.ts +++ b/examples/readme-order.prettier.ts @@ -25,6 +25,15 @@ import g from "."; import h from "./constants"; import i from "./styles"; +// TypeScript import assignments. +import J = require("../parent"); +import K = require("./sibling"); +export import L = require("an-npm-package"); +import M = require("different-npm-package"); +import N = Namespace; +export import O = Namespace.A.B.C; +import P = Namespace.A.C; + // Different types of exports: export { a } from "../.."; export { b } from "/"; diff --git a/src/imports.js b/src/imports.js index d284a70..e1db580 100644 --- a/src/imports.js +++ b/src/imports.js @@ -16,6 +16,8 @@ const defaultGroups = [ // Relative imports. // Anything that starts with a dot. ["^\\."], + // TypeScript import assignments. + ["^\\u0001", "^\\u0002"], ]; module.exports = { @@ -56,7 +58,7 @@ module.exports = { const parents = new Set(); return { - ImportDeclaration: (node) => { + "ImportDeclaration,TSImportEqualsDeclaration": (node) => { parents.add(node.parent); }, @@ -97,14 +99,16 @@ function makeSortedItems(items, outerGroups) { for (const item of items) { const { originalSource } = item.source; - const source = item.isSideEffectImport - ? `\0${originalSource}` - : item.source.kind !== "value" - ? `${originalSource}\0` - : originalSource; + const sourceWithControlCharacter = getSourceWithControlCharacter( + originalSource, + item + ); const [matchedGroup] = shared .flatMap(itemGroups, (groups) => - groups.map((group) => [group, group.regex.exec(source)]) + groups.map((group) => [ + group, + group.regex.exec(sourceWithControlCharacter), + ]) ) .reduce( ([group, longestMatch], [nextGroup, nextMatch]) => @@ -130,14 +134,41 @@ function makeSortedItems(items, outerGroups) { ); } +function getSourceWithControlCharacter(originalSource, item) { + if (item.isSideEffectImport) { + return `\0${originalSource}`; + } + switch (item.source.kind) { + case shared.KIND_VALUE: + return originalSource; + case shared.KIND_TS_IMPORT_ASSIGNMENT_REQUIRE: + return `\u0001${originalSource}`; + case shared.KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE: + return `\u0002${originalSource}`; + default: // `type` and `typeof`. + return `${originalSource}\u0000`; + } +} + // Exclude "ImportDefaultSpecifier" – the "def" in `import def, {a, b}`. function getSpecifiers(importNode) { - return importNode.specifiers.filter((node) => isImportSpecifier(node)); + switch (importNode.type) { + case "ImportDeclaration": + return importNode.specifiers.filter((node) => isImportSpecifier(node)); + case "TSImportEqualsDeclaration": + return []; + // istanbul ignore next + default: + throw new Error(`Unsupported import node type: ${importNode.type}`); + } } // Full import statement. function isImport(node) { - return node.type === "ImportDeclaration"; + return ( + node.type === "ImportDeclaration" || + node.type === "TSImportEqualsDeclaration" + ); } // import def, { a, b as c, type d } from "A" @@ -150,9 +181,21 @@ function isImportSpecifier(node) { // But not: import {} from "setup" // And not: import type {} from "setup" function isSideEffectImport(importNode, sourceCode) { - return ( - importNode.specifiers.length === 0 && - (!importNode.importKind || importNode.importKind === "value") && - !shared.isPunctuator(sourceCode.getFirstToken(importNode, { skip: 1 }), "{") - ); + switch (importNode.type) { + case "ImportDeclaration": + return ( + importNode.specifiers.length === 0 && + (!importNode.importKind || + importNode.importKind === shared.KIND_VALUE) && + !shared.isPunctuator( + sourceCode.getFirstToken(importNode, { skip: 1 }), + "{" + ) + ); + case "TSImportEqualsDeclaration": + return false; + // istanbul ignore next + default: + throw new Error(`Unsupported import node type: ${importNode.type}`); + } } diff --git a/src/shared.js b/src/shared.js index 60c92d9..0d10135 100644 --- a/src/shared.js +++ b/src/shared.js @@ -164,7 +164,7 @@ function getImportExportItems( const [start] = all[0].range; const [, end] = all[all.length - 1].range; - const source = getSource(node); + const source = getSource(sourceCode, node); return { node, @@ -795,8 +795,8 @@ function isNewline(node) { return node.type === "Newline"; } -function getSource(node) { - const source = node.source.value; +function getSource(sourceCode, node) { + const [source, kind] = getSourceTextAndKind(sourceCode, node); return { // Sort by directory level rather than by string length. @@ -806,7 +806,7 @@ function getSource(node) { // Make `../` sort after `../../` but before `../a` etc. // Why a comma? See the next comment. .replace(/^[./]*\/$/, "$&,") - // Make `.` and `/` sort before any other punctation. + // Make `.` and `/` sort before any other punctuation. // The default order is: _ - , x x x . x x x / x x x // We’re changing it to: . / , x x x _ x x x - x x x .replace(/[./_-]/g, (char) => { @@ -825,16 +825,85 @@ function getSource(node) { } }), originalSource: source, - kind: getImportExportKind(node), + kind, }; } +function getSourceTextAndKind(sourceCode, node) { + switch (node.type) { + case "ImportDeclaration": + case "ExportNamedDeclaration": + case "ExportAllDeclaration": + return [node.source.value, getImportExportKind(node)]; + case "TSImportEqualsDeclaration": + return getSourceTextAndKindFromModuleReference( + sourceCode, + node.moduleReference + ); + // istanbul ignore next + default: + throw new Error(`Unsupported import/export node type: ${node.type}`); + } +} + +const KIND_VALUE = "value"; +const KIND_TS_IMPORT_ASSIGNMENT_REQUIRE = "z_require"; +const KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE = "z_namespace"; + +function getSourceTextAndKindFromModuleReference(sourceCode, node) { + switch (node.type) { + case "TSExternalModuleReference": + // Only string literals inside `require()` are allowed by + // TypeScript, but the parser supports anything. Sorting + // is defined for string literals only. For other expressions, + // we just make sure not to crash. + switch (node.expression.type) { + case "Literal": + return [ + typeof node.expression.value === "string" + ? node.expression.value + : node.expression.raw, + KIND_TS_IMPORT_ASSIGNMENT_REQUIRE, + ]; + default: { + const [start, end] = node.expression.range; + return [ + sourceCode.text.slice(start, end), + KIND_TS_IMPORT_ASSIGNMENT_REQUIRE, + ]; + } + } + case "TSQualifiedName": + return [ + getSourceTextFromTSQualifiedName(sourceCode, node), + KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE, + ]; + case "Identifier": + return [node.name, KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE]; + // istanbul ignore next + default: + throw new Error(`Unsupported module reference node type: ${node.type}`); + } +} + +function getSourceTextFromTSQualifiedName(sourceCode, node) { + switch (node.left.type) { + case "Identifier": + return `${node.left.name}.${node.right.name}`; + case "TSQualifiedName": + return `${getSourceTextFromTSQualifiedName(sourceCode, node.left)}.${ + node.right.name + }`; + // istanbul ignore next + default: + throw new Error(`Unsupported TS qualified name node type: ${node.type}`); + } +} + function getImportExportKind(node) { // `type` and `typeof` imports, as well as `type` exports (there are no - // `typeof` exports). In Flow, import specifiers can also have a kind. Default - // to "value" (like TypeScript) to make regular imports/exports come after the - // type imports/exports. - return node.importKind || node.exportKind || "value"; + // `typeof` exports). + return node.importKind || node.exportKind || KIND_VALUE; } // Like `Array.prototype.findIndex`, but searches from the end. @@ -859,6 +928,9 @@ module.exports = { flatMap, getImportExportItems, isPunctuator, + KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE, + KIND_TS_IMPORT_ASSIGNMENT_REQUIRE, + KIND_VALUE, maybeReportSorting, printSortedItems, printWithSortedSpecifiers, diff --git a/test/__snapshots__/examples.test.js.snap b/test/__snapshots__/examples.test.js.snap index c706ca1..803dbff 100644 --- a/test/__snapshots__/examples.test.js.snap +++ b/test/__snapshots__/examples.test.js.snap @@ -496,6 +496,15 @@ import g from "."; import h from "./constants"; import i from "./styles"; +// TypeScript import assignments. +import J = require("../parent"); +import K = require("./sibling"); +export import L = require("an-npm-package"); +import M = require("different-npm-package"); +import N = Namespace; +export import O = Namespace.A.B.C; +import P = Namespace.A.C; + // Different types of exports: export { a } from "../.."; export { b } from "/"; diff --git a/test/imports.test.js b/test/imports.test.js index 4ddd5fb..6707a66 100644 --- a/test/imports.test.js +++ b/test/imports.test.js @@ -1846,6 +1846,13 @@ const typescriptTests = { `import type {} from "a"`, `import type { } from "a"`, `import json from "./foo.json" assert { type: "json" };`, + `import A = B`, + `import A = B.C`, + `import A = require("A")`, + + // These are parsed as type imports, but they are not valid in TypeScript: + `import A = require(1)`, + `import A = require({ b, a })`, // type specifiers. `import { a, type b, c, type d } from "a"`, @@ -1855,6 +1862,13 @@ const typescriptTests = { |import type x1 from "a"; |import type x2 from "b" `, + input` + |export namespace Foo { + | import A = _A; + | import B = _B; + | export import Enum = _Enum; + |} + `, ], invalid: [ // Type imports. @@ -2037,6 +2051,203 @@ const typescriptTests = { }, errors: 3, }, + + // Import assignments. + { + code: input` + |import { Namespace } from './namespace'; + |import Foo = Namespace.Foo; + |import old = require('./old'); + |import { bar } from './a'; + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import { bar } from './a'; + |import { Namespace } from './namespace'; + | + |import old = require('./old'); + |import Foo = Namespace.Foo; + `); + }, + errors: 1, + }, + { + code: input` + |import _ = require(''); + |import _ = require(""); + |import _ = require(''); + |import B = require('./b'); + |import A = require('./a'); + |import Foo = require('foo'); + |import Foo = require('../foo'); + |import Foo = require('../'); + |import Foo = require('..'); + |import At = require("@org/name"); + |import fs = require("node:fs"); + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import _ = require(''); + |import _ = require(""); + |import _ = require(''); + |import Foo = require('..'); + |import Foo = require('../'); + |import Foo = require('../foo'); + |import A = require('./a'); + |import B = require('./b'); + |import At = require("@org/name"); + |import Foo = require('foo'); + |import fs = require("node:fs"); + `); + }, + errors: 1, + }, + { + // This is invalid TypeScript, but supported by the parser. + // The order here doesn’t matter – we should just not crash. + code: input` + |import A = require(null); + |import A = require(cool().thing); + |import A = require(1 + 1); + |import A = require({ a }); + |import A = require('foo'); + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import A = require({ a }); + |import A = require(1 + 1); + |import A = require(cool().thing); + |import A = require('foo'); + |import A = require(null); + `); + }, + errors: 1, + }, + { + code: input` + |/*1*/import/*2*/B/*3*/=/*4*/require/*5*/(/*6*/"B"/*7*/)/*8*/;/*9*//*10 + |*/import B + | //11 + | = require('A'); //12 + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |/*10 + |*/import B + | //11 + | = require('A'); //12 + |/*1*/import/*2*/B/*3*/=/*4*/require/*5*/(/*6*/"B"/*7*/)/*8*/;/*9*/ + `); + }, + errors: 1, + }, + { + code: input` + |/*1*/import/*2*/B/*3*/=/*4*/Namespace/*5*/./*5*/B/*6*/;/*7*//*8 + |*/import AB = Namespace.A.B; //9 + |import A = + | //10 + | Namespace.A.A; + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import A = + | //10 + | Namespace.A.A; + |/*8 + |*/import AB = Namespace.A.B; //9 + |/*1*/import/*2*/B/*3*/=/*4*/Namespace/*5*/./*5*/B/*6*/;/*7*/ + `); + }, + errors: 1, + }, + { + code: input` + |export namespace Foo { + | import B = _B; + | export import Enum = _Enum; + | import A = _A; + |} + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |export namespace Foo { + | import A = _A; + | import B = _B; + | export import Enum = _Enum; + |} + `); + }, + errors: 1, + }, + { + options: [{ groups: [] }], + code: input` + |import {} from 'A.B.C'; + |import type {} from 'A.B.C'; + |import A = require('A.B.C'); + |import B = A.B.C; + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import type {} from 'A.B.C'; + |import {} from 'A.B.C'; + |import B = A.B.C; + |import A = require('A.B.C'); + `); + }, + errors: 1, + }, + { + options: [{ groups: [] }], + code: input` + |import type {} from 'D'; + |import {} from 'C'; + |import B = B; + |import A = require('A'); + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import A = require('A'); + |import B = B; + |import {} from 'C'; + |import type {} from 'D'; + `); + }, + errors: 1, + }, + { + options: [{ groups: [] }], + code: input` + |import A = require('./A'); + |import {} from '../parent'; + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import {} from '../parent'; + |import A = require('./A'); + `); + }, + errors: 1, + }, + { + options: [{ groups: [["^\\u0002"], ["^"], ["^\\u0001"]] }], + code: input` + |import A = require('./A'); + |import {} from '../parent'; + |import B = B; + `, + output: (actual) => { + expect(actual).toMatchInlineSnapshot(` + |import B = B; + | + |import {} from '../parent'; + | + |import A = require('./A'); + `); + }, + errors: 1, + }, ], };