Skip to content

Commit

Permalink
Add support for TypeScript import assignments (#149)
Browse files Browse the repository at this point in the history
Closes #144
  • Loading branch information
MillerSvt authored Feb 8, 2024
1 parent c641891 commit c07aeca
Show file tree
Hide file tree
Showing 7 changed files with 390 additions and 28 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/";
Expand Down Expand Up @@ -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:
Expand All @@ -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"],
];
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.)
Expand Down
11 changes: 7 additions & 4 deletions examples/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
},
],
},
Expand All @@ -115,7 +115,7 @@ module.exports = {
"error",
{
// The default grouping, but in reverse.
groups: [["^\\."], ["^"], ["^@?\\w"], ["^node:"], ["^\\u0000"]],
groups: [["^\\u0001", "^\\u0002"], ["^\\."], ["^"], ["^@?\\w"], ["^node:"], ["^\\u0000"]],
},
],
},
Expand All @@ -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"]],
},
],
},
Expand All @@ -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$"]],
},
],
},
Expand All @@ -162,6 +162,7 @@ module.exports = {
["^@?\\w"],
["^"],
["^\\."],
["^\\u0001", "^\\u0002"],
],
},
],
Expand All @@ -182,6 +183,7 @@ module.exports = {
["^@?\\w"],
["^"],
["^\\."],
["^\\u0001", "^\\u0002"],
["^node:.*\\u0000$", "^@?\\w.*\\u0000$", "^[^.].*\\u0000$", "^\\..*\\u0000$"],
],
},
Expand All @@ -202,6 +204,7 @@ module.exports = {
["^@?\\w.*\\u0000$", "^@?\\w"],
["(?<=\\u0000)$", "^"],
["^\\..*\\u0000$", "^\\."],
["^\\u0001", "^\\u0002"],
],
},
],
Expand Down
9 changes: 9 additions & 0 deletions examples/readme-order.prettier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/";
Expand Down
71 changes: 57 additions & 14 deletions src/imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const defaultGroups = [
// Relative imports.
// Anything that starts with a dot.
["^\\."],
// TypeScript import assignments.
["^\\u0001", "^\\u0002"],
];

module.exports = {
Expand Down Expand Up @@ -56,7 +58,7 @@ module.exports = {
const parents = new Set();

return {
ImportDeclaration: (node) => {
"ImportDeclaration,TSImportEqualsDeclaration": (node) => {
parents.add(node.parent);
},

Expand Down Expand Up @@ -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]) =>
Expand All @@ -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"
Expand All @@ -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}`);
}
}
90 changes: 81 additions & 9 deletions src/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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) => {
Expand All @@ -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.
Expand All @@ -859,6 +928,9 @@ module.exports = {
flatMap,
getImportExportItems,
isPunctuator,
KIND_TS_IMPORT_ASSIGNMENT_NAMESPACE,
KIND_TS_IMPORT_ASSIGNMENT_REQUIRE,
KIND_VALUE,
maybeReportSorting,
printSortedItems,
printWithSortedSpecifiers,
Expand Down
9 changes: 9 additions & 0 deletions test/__snapshots__/examples.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/";
Expand Down
Loading

0 comments on commit c07aeca

Please sign in to comment.