/// /** * A lint rule that holds one module's entry in a file's import list to one * statement of each kind. * * A module is imported in one statement, marking any type-only names inline, * or in two when one of them is an `import type` and the other is a value * import. What this rule reports is a second statement of the same kind for * one module: two value imports of it, or two `import type`s of it. Written * that way, one dependency reads as two, and the second is easy to miss when * the first is being edited or removed. * * A bare `import "x";` is a value statement, so it counts toward that module's * total, and what it counts against decides what happens to it. Beside a value * import that binds something, it adds nothing, since that import evaluates * the module, side effects included: the rule reports the bare statement. * Beside nothing but an `import type`, it is the only statement that evaluates * the module at all, and the rule leaves the pair alone as two statements of * different kinds. * * Between those sits a statement that names nothing but types, `import { type * Thing } from "x";`. It is a value statement to the grammar, and TypeScript * erases it as it erases an `import type`, so it evaluates nothing either. * Beside a bare import of the same module, the rule reports that statement * rather than the bare one, because writing it `import type` is what makes the * pair legal and leaves the module evaluated. * * An `import type` with an empty clause, `import type {} from "x";`, binds * nothing and evaluates nothing. The split above is about which statements * evaluate the module, and an `import type` answers that the same way whatever * its clause holds, so an empty one counts toward the module's `import type` * total like any other rather than standing apart as a bare statement does. * * Whichever statement survives a merge goes where the earliest of the * statements it replaces sat. That is what keeps the module's evaluation at * the point in the list it was already reached from, which matters when the * module installs something that an import below it reads as that one loads. * * One pair has no merge at all: a namespace import takes no other name beside * it, so `import * as name from "x"` and a named import of `x` of the same * kind cannot become one statement. Usually the fix is to reach through the * namespace for what the named statement took. Where that reads worse — a * namespace imported only so its `typeof` names the whole module, against a * list of names the file uses throughout — the pair stays, and takes a * `deno-lint-ignore` saying which case it is. * * The rule reads a specifier as written, so two spellings of one module — * `@/thing.ts` and `../thing.ts` — are two modules to it. Which spelling to * use is a separate rule of the same section, and one for review. * * See the `Imports` section of docs/development/DEVELOPMENT.md. */ /** Which of the two kinds of statement a declaration is. */ type Kind = Deno.lint.ImportDeclaration["importKind"]; const REFERENCE = "See the `Imports` section of docs/development/DEVELOPMENT.md."; /** What a message calls a statement of one kind. */ function kindWord(kind: Kind): string { return kind === "type" ? "`import type`" : "value import"; } /** Reports a second statement of one kind naming a module. */ function mergeMessage(kind: Kind, specifier: string): string { return `This file already imports \`${specifier}\` in another ` + `${kindWord(kind)} above. A module takes one statement of each kind, so ` + "merge the two lists of names into one statement, marking a type-only " + "name inline with `type` where that is what the merge needs. The " + "statement that stays goes where the earlier of the two sat. Written as " + "two, one dependency reads as two, and the second is easy to miss when " + `the first is being edited or removed. ${REFERENCE}`; } /** Reports a second statement of one kind where one takes a namespace. */ function mergeThroughNamespaceMessage(kind: Kind, specifier: string): string { return `This file already imports \`${specifier}\` in another ` + `${kindWord(kind)} above, and one of the two takes the module as a ` + "namespace, which no other name may sit beside. So these two cannot " + "become one statement: reach through the namespace for what the other " + "one named, and drop that statement. Where that reads worse than the " + "pair — a namespace imported only so its `typeof` names the whole " + "module, against a list of names the file uses throughout — keep both " + "and say so with `// deno-lint-ignore " + `cf-import-list/one-statement-per-kind\`. ${REFERENCE}`; } /** Reports a bare import beside a statement that binds something. */ function bareBesideValueMessage(specifier: string): string { return `This file also imports \`${specifier}\` in a value import that ` + "names what it takes, and a value import evaluates the module, side " + "effects included. This bare statement adds nothing beside it: drop it, " + "and say in a comment on the statement that stays what the bare one was " + "there for. That statement goes where the earlier of the two sat, so the " + `module is still evaluated at the same point in the list. ${REFERENCE}`; } /** * Reports a statement naming nothing but types beside a bare import of the * same module. */ function typesBesideBareMessage(specifier: string): string { return `This file also imports \`${specifier}\` for its side effect, in a ` + "bare `import` statement. This statement names nothing but types, so " + "TypeScript erases the whole of it and the bare statement is what " + "evaluates the module. Write this one as `import type`: the two are then " + "one statement of each kind, which is the second of the two shapes the " + `section allows. ${REFERENCE}`; } /** Reports a bare import beside another bare import of the same module. */ function repeatedBareMessage(specifier: string): string { return `This file already imports \`${specifier}\` for its side effect ` + "above. A module is evaluated once however many times it is imported, " + `so this statement does nothing: drop it. ${REFERENCE}`; } /** The statements of one kind naming one module, in the order written. */ interface Run { readonly kind: Kind; readonly specifier: string; /** The statements that name what they take. */ readonly named: Deno.lint.ImportDeclaration[]; /** The bare statements, which name nothing and run the module. */ readonly bare: Deno.lint.ImportDeclaration[]; } /** * Whether a statement binds anything the program can reach at run time. A * statement whose every name is marked `type` inline binds nothing, and * TypeScript erases it along with those names, so it does not evaluate the * module it names. */ function bindsValue(node: Deno.lint.ImportDeclaration): boolean { return node.specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type" ); } /** Whether a statement takes the whole module under one name. */ function takesNamespace(node: Deno.lint.ImportDeclaration): boolean { return node.specifiers.some((specifier) => specifier.type === "ImportNamespaceSpecifier" ); } export default { name: "cf-import-list", rules: { "one-statement-per-kind": { create(context) { const runs = new Map(); return { ImportDeclaration(node) { const kind = node.importKind; const specifier = node.source.value; const key = `${kind} ${specifier}`; let run = runs.get(key); if (run === undefined) { run = { kind, specifier, named: [], bare: [] }; runs.set(key, run); } // The split is about which statements evaluate the module, which // is a question only a value statement raises: an `import type` // evaluates nothing whatever its clause holds, so an empty one // counts toward its kind rather than standing apart as bare. const statements = kind === "type" || node.specifiers.length > 0 ? run.named : run.bare; statements.push(node); }, "Program:exit"() { for (const { kind, specifier, named, bare } of runs.values()) { // A statement merges into the first of its kind, so what // decides which message it takes is whether either of those two // takes a namespace, rather than whether any statement in the // run does. for (const node of named.slice(1)) { const throughNamespace = takesNamespace(named[0]) || takesNamespace(node); context.report({ node, message: throughNamespace ? mergeThroughNamespaceMessage(kind, specifier) : mergeMessage(kind, specifier), }); } if (kind === "type" || bare.length === 0) continue; if (named.some(bindsValue)) { for (const node of bare) { context.report({ node, message: bareBesideValueMessage(specifier), }); } continue; } // Nothing here evaluates the module but the bare statements, so // the first of those stays and the rest repeat it. A statement // naming nothing but types is spelled to say so. if (named.length > 0) { context.report({ node: named[0], message: typesBesideBareMessage(specifier), }); } for (const node of bare.slice(1)) { context.report({ node, message: repeatedBareMessage(specifier), }); } } }, }; }, }, }, } satisfies Deno.lint.Plugin;