Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for TypeScript import assignments #149

Merged
merged 13 commits into from
Feb 8, 2024
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"
lydell marked this conversation as resolved.
Show resolved Hide resolved
);
}

// 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
Loading