securityos/node_modules/eslint-plugin-simple-import.../shared.js

867 lines
27 KiB
JavaScript
Raw Permalink Normal View History

2024-09-06 15:32:35 +00:00
"use strict";
// A “chunk” is a sequence of statements of a certain type with only comments
// and whitespace between.
function extractChunks(parentNode, isPartOfChunk) {
const chunks = [];
let chunk = [];
let lastNode = undefined;
for (const node of parentNode.body) {
const result = isPartOfChunk(node, lastNode);
switch (result) {
case "PartOfChunk":
chunk.push(node);
break;
case "PartOfNewChunk":
if (chunk.length > 0) {
chunks.push(chunk);
}
chunk = [node];
break;
case "NotPartOfChunk":
if (chunk.length > 0) {
chunks.push(chunk);
chunk = [];
}
break;
// istanbul ignore next
default:
throw new Error(`Unknown chunk result: ${result}`);
}
lastNode = node;
}
if (chunk.length > 0) {
chunks.push(chunk);
}
return chunks;
}
function maybeReportSorting(context, sorted, start, end) {
const sourceCode = context.getSourceCode();
const original = sourceCode.getText().slice(start, end);
if (original !== sorted) {
context.report({
messageId: "sort",
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(end),
},
fix: (fixer) => fixer.replaceTextRange([start, end], sorted),
});
}
}
function printSortedItems(sortedItems, originalItems, sourceCode) {
const newline = guessNewline(sourceCode);
const sorted = sortedItems
.map((groups) =>
groups
.map((groupItems) => groupItems.map((item) => item.code).join(newline))
.join(newline)
)
.join(newline + newline);
// Edge case: If the last import/export (after sorting) ends with a line
// comment and theres code (or a multiline block comment) on the same line,
// add a newline so we dont accidentally comment stuff out.
const flattened = flatMap(sortedItems, (groups) => [].concat(...groups));
const lastSortedItem = flattened[flattened.length - 1];
const lastOriginalItem = originalItems[originalItems.length - 1];
const nextToken = lastSortedItem.needsNewline
? sourceCode.getTokenAfter(lastOriginalItem.node, {
includeComments: true,
filter: (token) =>
!isLineComment(token) &&
!(
isBlockComment(token) &&
token.loc.end.line === lastOriginalItem.node.loc.end.line
),
})
: undefined;
const maybeNewline =
nextToken != null &&
nextToken.loc.start.line === lastOriginalItem.node.loc.end.line
? newline
: "";
return sorted + maybeNewline;
}
// Wrap the import/export nodes in `passedChunk` in objects with more data about
// the import/export. Most importantly theres a `code` property that contains
// the node as a string, with comments (if any). Finding the corresponding
// comments is the hard part.
function getImportExportItems(
passedChunk,
sourceCode,
isSideEffectImport,
getSpecifiers
) {
const chunk = handleLastSemicolon(passedChunk, sourceCode);
return chunk.map((node, nodeIndex) => {
const lastLine =
nodeIndex === 0
? node.loc.start.line - 1
: chunk[nodeIndex - 1].loc.end.line;
// Get all comments before the import/export, except:
//
// - Comments on another line for the first import/export.
// - Comments that belong to the previous import/export (if any) that is,
// comments that are on the same line as the previous import/export. But
// multiline block comments always belong to this import/export, not the
// previous.
const commentsBefore = sourceCode
.getCommentsBefore(node)
.filter(
(comment) =>
comment.loc.start.line <= node.loc.start.line &&
comment.loc.end.line > lastLine &&
(nodeIndex > 0 || comment.loc.start.line > lastLine)
);
// Get all comments after the import/export that are on the same line.
// Multiline block comments belong to the _next_ import/export (or the
// following code in case of the last import/export).
const commentsAfter = sourceCode
.getCommentsAfter(node)
.filter((comment) => comment.loc.end.line === node.loc.end.line);
const before = printCommentsBefore(node, commentsBefore, sourceCode);
const after = printCommentsAfter(node, commentsAfter, sourceCode);
// Print the indentation before the import/export or its first comment, if
// any, to support indentation in `<script>` tags.
const indentation = getIndentation(
commentsBefore.length > 0 ? commentsBefore[0] : node,
sourceCode
);
// Print spaces after the import/export or its last comment, if any, to
// avoid producing a sort error just because you accidentally added a few
// trailing spaces among the imports/exports.
const trailingSpaces = getTrailingSpaces(
commentsAfter.length > 0 ? commentsAfter[commentsAfter.length - 1] : node,
sourceCode
);
const code =
indentation +
before +
printWithSortedSpecifiers(node, sourceCode, getSpecifiers) +
after +
trailingSpaces;
const all = [...commentsBefore, node, ...commentsAfter];
const [start] = all[0].range;
const [, end] = all[all.length - 1].range;
const source = getSource(node);
return {
node,
code,
start: start - indentation.length,
end: end + trailingSpaces.length,
isSideEffectImport: isSideEffectImport(node, sourceCode),
source,
index: nodeIndex,
needsNewline:
commentsAfter.length > 0 &&
isLineComment(commentsAfter[commentsAfter.length - 1]),
};
});
}
// Parsers think that a semicolon after a statement belongs to that statement.
// But in a semicolon-free code style it might belong to the next statement:
//
// import x from "x"
// ;[].forEach()
//
// If the last import/export of a chunk ends with a semicolon, and that
// semicolon isnt located on the same line as the `from` string, adjust the
// node to end at the `from` string instead.
//
// In the above example, the import is adjusted to end after `"x"`.
function handleLastSemicolon(chunk, sourceCode) {
const lastIndex = chunk.length - 1;
const lastNode = chunk[lastIndex];
const [nextToLastToken, lastToken] = sourceCode.getLastTokens(lastNode, {
count: 2,
});
const lastIsSemicolon = isPunctuator(lastToken, ";");
if (!lastIsSemicolon) {
return chunk;
}
const semicolonBelongsToNode =
nextToLastToken.loc.end.line === lastToken.loc.start.line ||
// If theres no more code after the last import/export the semicolon has to
// belong to the import/export, even if it is not on the same line.
sourceCode.getTokenAfter(lastToken) == null;
if (semicolonBelongsToNode) {
return chunk;
}
// Preserve the start position, but use the end position of the `from` string.
const newLastNode = {
...lastNode,
range: [lastNode.range[0], nextToLastToken.range[1]],
loc: {
start: lastNode.loc.start,
end: nextToLastToken.loc.end,
},
};
return chunk.slice(0, lastIndex).concat(newLastNode);
}
function printWithSortedSpecifiers(node, sourceCode, getSpecifiers) {
const allTokens = getAllTokens(node, sourceCode);
const openBraceIndex = allTokens.findIndex((token) =>
isPunctuator(token, "{")
);
const closeBraceIndex = allTokens.findIndex((token) =>
isPunctuator(token, "}")
);
const specifiers = getSpecifiers(node);
if (
openBraceIndex === -1 ||
closeBraceIndex === -1 ||
specifiers.length <= 1
) {
return printTokens(allTokens);
}
const specifierTokens = allTokens.slice(openBraceIndex + 1, closeBraceIndex);
const itemsResult = getSpecifierItems(specifierTokens, sourceCode);
const items = itemsResult.items.map((originalItem, index) => ({
...originalItem,
node: specifiers[index],
}));
const sortedItems = sortSpecifierItems(items);
const newline = guessNewline(sourceCode);
// `allTokens[closeBraceIndex - 1]` wouldnt work because `allTokens` contains
// comments and whitespace.
const hasTrailingComma = isPunctuator(
sourceCode.getTokenBefore(allTokens[closeBraceIndex]),
","
);
const lastIndex = sortedItems.length - 1;
const sorted = flatMap(sortedItems, (item, index) => {
const previous = index === 0 ? undefined : sortedItems[index - 1];
// Add a newline if the item needs one, unless the previous item (if any)
// already ends with a newline.
const maybeNewline =
previous != null &&
needsStartingNewline(item.before) &&
!(
previous.after.length > 0 &&
isNewline(previous.after[previous.after.length - 1])
)
? [{ type: "Newline", code: newline }]
: [];
if (index < lastIndex || hasTrailingComma) {
return [
...maybeNewline,
...item.before,
...item.specifier,
{ type: "Comma", code: "," },
...item.after,
];
}
const nonBlankIndex = item.after.findIndex(
(token) => !isNewline(token) && !isSpaces(token)
);
// Remove whitespace and newlines at the start of `.after` if the item had a
// comma before, but now hasnt to avoid blank lines and excessive
// whitespace before `}`.
const after = !item.hadComma
? item.after
: nonBlankIndex === -1
? []
: item.after.slice(nonBlankIndex);
return [...maybeNewline, ...item.before, ...item.specifier, ...after];
});
const maybeNewline =
needsStartingNewline(itemsResult.after) &&
!isNewline(sorted[sorted.length - 1])
? [{ type: "Newline", code: newline }]
: [];
return printTokens([
...allTokens.slice(0, openBraceIndex + 1),
...itemsResult.before,
...sorted,
...maybeNewline,
...itemsResult.after,
...allTokens.slice(closeBraceIndex),
]);
}
// Turns a list of tokens between the `{` and `}` of an import/export specifiers
// list into an object with the following properties:
//
// - before: Array of tokens whitespace and comments after the `{` that do not
// belong to any specifier.
// - after: Array of tokens whitespace and comments before the `}` that do not
// belong to any specifier.
// - items: Array of specifier items.
//
// Each specifier item looks like this:
//
// - before: Array of tokens whitespace and comments before the specifier.
// - after: Array of tokens whitespace and comments after the specifier.
// - specifier: Array of tokens identifiers, whitespace and comments of the
// specifier.
// - hadComma: A Boolean representing if the specifier had a comma originally.
//
// We have to do carefully preserve all original whitespace this way in order to
// be compatible with other stylistic ESLint rules.
function getSpecifierItems(tokens) {
const result = {
before: [],
after: [],
items: [],
};
let current = makeEmptyItem();
for (const token of tokens) {
switch (current.state) {
case "before":
switch (token.type) {
case "Newline":
current.before.push(token);
// All whitespace and comments before the first newline or
// identifier belong to the `{`, not the first specifier.
if (result.before.length === 0 && result.items.length === 0) {
result.before = current.before;
current = makeEmptyItem();
}
break;
case "Spaces":
case "Block":
case "Line":
current.before.push(token);
break;
// Weve reached an identifier.
default:
// All whitespace and comments before the first newline or
// identifier belong to the `{`, not the first specifier.
if (result.before.length === 0 && result.items.length === 0) {
result.before = current.before;
current = makeEmptyItem();
}
current.state = "specifier";
current.specifier.push(token);
}
break;
case "specifier":
switch (token.type) {
case "Punctuator":
// There can only be comma punctuators, but future-proof by checking.
// istanbul ignore else
if (isPunctuator(token, ",")) {
current.hadComma = true;
current.state = "after";
} else {
current.specifier.push(token);
}
break;
// When consuming the specifier part, we eat every token until a comma
// or to the end, basically.
default:
current.specifier.push(token);
}
break;
case "after":
switch (token.type) {
// Only whitespace and comments after a specifier that are on the same
// belong to the specifier.
case "Newline":
current.after.push(token);
result.items.push(current);
current = makeEmptyItem();
break;
case "Spaces":
case "Line":
current.after.push(token);
break;
case "Block":
// Multiline block comments belong to the next specifier.
if (hasNewline(token.code)) {
result.items.push(current);
current = makeEmptyItem();
current.before.push(token);
} else {
current.after.push(token);
}
break;
// Weve reached another specifier time to process that one.
default:
result.items.push(current);
current = makeEmptyItem();
current.state = "specifier";
current.specifier.push(token);
}
break;
// istanbul ignore next
default:
throw new Error(`Unknown state: ${current.state}`);
}
}
// Weve reached the end of the tokens. Handle whats currently in `current`.
switch (current.state) {
// If the last specifier has a trailing comma and some of the remaining
// whitespace and comments are on the same line we end up here. If so we
// want to put that whitespace and comments in `result.after`.
case "before":
result.after = current.before;
break;
// If the last specifier has no trailing comma we end up here. Move all
// trailing comments and whitespace from `.specifier` to `.after`, and
// comments and whitespace that dont belong to the specifier to
// `result.after`. The last non-comment and non-whitespace token is usually
// an identifier, but in this case its a keyword:
//
// export { z, d as default } from "a"
case "specifier": {
const lastIdentifierIndex = findLastIndex(
current.specifier,
(token2) => isIdentifier(token2) || isKeyword(token2)
);
const specifier = current.specifier.slice(0, lastIdentifierIndex + 1);
const after = current.specifier.slice(lastIdentifierIndex + 1);
// If theres a newline, put everything up to and including (hence the `+
// 1`) that newline in the specifierss `.after`.
const newlineIndexRaw = after.findIndex((token2) => isNewline(token2));
const newlineIndex = newlineIndexRaw === -1 ? -1 : newlineIndexRaw + 1;
// If theres a multiline block comment, put everything _befor_ that
// comment in the specifierss `.after`.
const multilineBlockCommentIndex = after.findIndex(
(token2) => isBlockComment(token2) && hasNewline(token2.code)
);
const sliceIndex =
// If both a newline and a multiline block comment exists, choose the
// earlier one.
newlineIndex >= 0 && multilineBlockCommentIndex >= 0
? Math.min(newlineIndex, multilineBlockCommentIndex)
: newlineIndex >= 0
? newlineIndex
: multilineBlockCommentIndex >= 0
? multilineBlockCommentIndex
: // If there are no newlines, move the last whitespace into `result.after`.
endsWithSpaces(after)
? after.length - 1
: -1;
current.specifier = specifier;
current.after = sliceIndex === -1 ? after : after.slice(0, sliceIndex);
result.items.push(current);
result.after = sliceIndex === -1 ? [] : after.slice(sliceIndex);
break;
}
// If the last specifier has a trailing comma and all remaining whitespace
// and comments are on the same line we end up here. If so we want to move
// the final whitespace to `result.after`.
case "after":
if (endsWithSpaces(current.after)) {
const last = current.after.pop();
result.after = [last];
}
result.items.push(current);
break;
// istanbul ignore next
default:
throw new Error(`Unknown state: ${current.state}`);
}
return result;
}
function makeEmptyItem() {
return {
// "before" | "specifier" | "after"
state: "before",
before: [],
after: [],
specifier: [],
hadComma: false,
};
}
// If a specifier item starts with a line comment or a singleline block comment
// it needs a newline before that. Otherwise that comment can end up belonging
// to the _previous_ specifier after sorting.
function needsStartingNewline(tokens) {
const before = tokens.filter((token) => !isSpaces(token));
if (before.length === 0) {
return false;
}
const firstToken = before[0];
return (
isLineComment(firstToken) ||
(isBlockComment(firstToken) && !hasNewline(firstToken.code))
);
}
function endsWithSpaces(tokens) {
const last = tokens.length > 0 ? tokens[tokens.length - 1] : undefined;
return last == null ? false : isSpaces(last);
}
const NEWLINE = /(\r?\n)/;
function hasNewline(string) {
return NEWLINE.test(string);
}
function guessNewline(sourceCode) {
const match = NEWLINE.exec(sourceCode.text);
return match == null ? "\n" : match[0];
}
function parseWhitespace(whitespace) {
const allItems = whitespace.split(NEWLINE);
// Remove blank lines. `allItems` contains alternating `spaces` (which can be
// the empty string) and `newline` (which is either "\r\n" or "\n"). So in
// practice `allItems` grows like this as there are more newlines in
// `whitespace`:
//
// [spaces]
// [spaces, newline, spaces]
// [spaces, newline, spaces, newline, spaces]
// [spaces, newline, spaces, newline, spaces, newline, spaces]
//
// If there are 5 or more items we have at least one blank line. If so, keep
// the first `spaces`, the first `newline` and the last `spaces`.
const items =
allItems.length >= 5
? allItems.slice(0, 2).concat(allItems.slice(-1))
: allItems;
return (
items
.map((spacesOrNewline, index) =>
index % 2 === 0
? { type: "Spaces", code: spacesOrNewline }
: { type: "Newline", code: spacesOrNewline }
)
// Remove empty spaces since it makes debugging easier.
.filter((token) => token.code !== "")
);
}
function removeBlankLines(whitespace) {
return printTokens(parseWhitespace(whitespace));
}
// Returns `sourceCode.getTokens(node)` plus whitespace and comments. All tokens
// have a `code` property with `sourceCode.getText(token)`.
function getAllTokens(node, sourceCode) {
const tokens = sourceCode.getTokens(node);
const lastTokenIndex = tokens.length - 1;
return flatMap(tokens, (token, tokenIndex) => {
const newToken = { ...token, code: sourceCode.getText(token) };
if (tokenIndex === lastTokenIndex) {
return [newToken];
}
const comments = sourceCode.getCommentsAfter(token);
const last = comments.length > 0 ? comments[comments.length - 1] : token;
const nextToken = tokens[tokenIndex + 1];
return [
newToken,
...flatMap(comments, (comment, commentIndex) => {
const previous =
commentIndex === 0 ? token : comments[commentIndex - 1];
return [
...parseWhitespace(
sourceCode.text.slice(previous.range[1], comment.range[0])
),
{ ...comment, code: sourceCode.getText(comment) },
];
}),
...parseWhitespace(
sourceCode.text.slice(last.range[1], nextToken.range[0])
),
];
});
}
// Prints tokens that are enhanced with a `code` property like those returned
// by `getAllTokens` and `parseWhitespace`.
function printTokens(tokens) {
return tokens.map((token) => token.code).join("");
}
// `comments` is a list of comments that occur before `node`. Print those and
// the whitespace between themselves and between `node`.
function printCommentsBefore(node, comments, sourceCode) {
const lastIndex = comments.length - 1;
return comments
.map((comment, index) => {
const next = index === lastIndex ? node : comments[index + 1];
return (
sourceCode.getText(comment) +
removeBlankLines(sourceCode.text.slice(comment.range[1], next.range[0]))
);
})
.join("");
}
// `comments` is a list of comments that occur after `node`. Print those and
// the whitespace between themselves and between `node`.
function printCommentsAfter(node, comments, sourceCode) {
return comments
.map((comment, index) => {
const previous = index === 0 ? node : comments[index - 1];
return (
removeBlankLines(
sourceCode.text.slice(previous.range[1], comment.range[0])
) + sourceCode.getText(comment)
);
})
.join("");
}
function getIndentation(node, sourceCode) {
const tokenBefore = sourceCode.getTokenBefore(node, {
includeComments: true,
});
if (tokenBefore == null) {
const text = sourceCode.text.slice(0, node.range[0]);
const lines = text.split(NEWLINE);
return lines[lines.length - 1];
}
const text = sourceCode.text.slice(tokenBefore.range[1], node.range[0]);
const lines = text.split(NEWLINE);
return lines.length > 1 ? lines[lines.length - 1] : "";
}
function getTrailingSpaces(node, sourceCode) {
const tokenAfter = sourceCode.getTokenAfter(node, {
includeComments: true,
});
if (tokenAfter == null) {
const text = sourceCode.text.slice(node.range[1]);
const lines = text.split(NEWLINE);
return lines[0];
}
const text = sourceCode.text.slice(node.range[1], tokenAfter.range[0]);
const lines = text.split(NEWLINE);
return lines[0];
}
function sortImportExportItems(items) {
return items.slice().sort((itemA, itemB) =>
// If both items are side effect imports, keep their original order.
itemA.isSideEffectImport && itemB.isSideEffectImport
? itemA.index - itemB.index
: // If one of the items is a side effect import, move it first.
itemA.isSideEffectImport
? -1
: itemB.isSideEffectImport
? 1
: // Compare the `from` part.
compare(itemA.source.source, itemB.source.source) ||
// The `.source` has been slightly tweaked. To stay fully deterministic,
// also sort on the original value.
compare(itemA.source.originalSource, itemB.source.originalSource) ||
// Then put type imports/exports before regular ones.
compare(itemA.source.kind, itemB.source.kind) ||
// Keep the original order if the sources are the same. Its not worth
// trying to compare anything else, and you can use `import/no-duplicates`
// to get rid of the problem anyway.
itemA.index - itemB.index
);
}
function sortSpecifierItems(items) {
return items.slice().sort(
(itemA, itemB) =>
// Compare by imported or exported name (external interface name).
// import { a as b } from "a"
// ^
// export { b as a }
// ^
compare(
(itemA.node.imported || itemA.node.exported).name,
(itemB.node.imported || itemB.node.exported).name
) ||
// Then compare by the file-local name.
// import { a as b } from "a"
// ^
// export { b as a }
// ^
compare(itemA.node.local.name, itemB.node.local.name) ||
// Then put type specifiers before regular ones.
compare(
getImportExportKind(itemA.node),
getImportExportKind(itemB.node)
) ||
// Keep the original order if the names are the same. Its not worth
// trying to compare anything else, `import {a, a} from "mod"` is a syntax
// error anyway (but @babel/eslint-parser kind of supports it).
// istanbul ignore next
itemA.index - itemB.index
);
}
const collator = new Intl.Collator("en", {
sensitivity: "base",
numeric: true,
});
function compare(a, b) {
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
}
function isIdentifier(node) {
return node.type === "Identifier";
}
function isKeyword(node) {
return node.type === "Keyword";
}
function isPunctuator(node, value) {
return node.type === "Punctuator" && node.value === value;
}
function isBlockComment(node) {
return node.type === "Block";
}
function isLineComment(node) {
return node.type === "Line";
}
function isSpaces(node) {
return node.type === "Spaces";
}
function isNewline(node) {
return node.type === "Newline";
}
function getSource(node) {
const source = node.source.value;
return {
// Sort by directory level rather than by string length.
source: source
// Treat `.` as `./`, `..` as `../`, `../..` as `../../` etc.
.replace(/^[./]*\.$/, "$&/")
// Make `../` sort after `../../` but before `../a` etc.
// Why a comma? See the next comment.
.replace(/^[./]*\/$/, "$&,")
// Make `.` and `/` sort before any other punctation.
// The default order is: _ - , x x x . x x x / x x x
// Were changing it to: . / , x x x _ x x x - x x x
.replace(/[./_-]/g, (char) => {
switch (char) {
case ".":
return "_";
case "/":
return "-";
case "_":
return ".";
case "-":
return "/";
// istanbul ignore next
default:
throw new Error(`Unknown source substitution character: ${char}`);
}
}),
originalSource: source,
kind: getImportExportKind(node),
};
}
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";
}
// Like `Array.prototype.findIndex`, but searches from the end.
function findLastIndex(array, fn) {
for (let index = array.length - 1; index >= 0; index--) {
if (fn(array[index], index, array)) {
return index;
}
}
// There are currently no usages of `findLastIndex` where nothing is found.
// istanbul ignore next
return -1;
}
// Like `Array.prototype.flatMap`, had it been available.
function flatMap(array, fn) {
return [].concat(...array.map(fn));
}
module.exports = {
extractChunks,
flatMap,
getImportExportItems,
isPunctuator,
maybeReportSorting,
printSortedItems,
printWithSortedSpecifiers,
sortImportExportItems,
};