///
/**
* Lint rules that keep a module's dependencies readable off the top of its
* file.
*
* A file's `import` declarations are the list of what it depends on. Two
* constructs write a dependency somewhere else instead:
*
* - `import("./mod.ts").Thing` in a type position. This form exists so that
* a `.d.ts` can name a type without a declaration of its own; in ordinary
* source it only hides an edge and repeats the module specifier at every
* use. TypeScript erases `import type` entirely, so the top-of-file form
* is equivalent everywhere except in an ambient declaration file that has
* no imports, where adding one would make the file a module and its
* `declare` statements stop being global. `no-inline-type-import` reports
* every inline type; that one shape takes a directive.
* - `import("./mod.ts")` as an expression. This one has real uses: loading a
* module the environment may not have, deferring a load that costs
* something at import time, or fetching a module instance at a moment the
* code chooses. `no-inline-module-import` reports the form that names its
* module with a plain string, which is the form that has a top-of-file
* equivalent, and leaves a computed specifier alone because that one has
* none.
*
* A deliberate deferred load stays, with the reason written above it. A Deno
* directive applies to the line right after it, so the directive itself is the
* last line of the comment block:
*
* // @db/sqlite opens its native library as it loads, and this path runs
* // without it.
* // deno-lint-ignore cf-imports/no-inline-module-import
* const { open } = await import("./sqlite-store.ts");
*
* A file whose whole subject is module loading takes one directive at the top
* instead of one per site:
*
* // deno-lint-ignore-file cf-imports/no-inline-module-import -- each test
* // installs its browser globals before the view module loads.
*
* A file gets one of those and no more: Deno reads the first and ignores any
* that follow, so a second directive suppresses nothing and reads as though it
* did. A file that already carries one names every rule it needs on that one
* line.
*
* // deno-lint-ignore-file no-explicit-any cf-imports/no-inline-module-import
*
* See docs/development/imports.md.
*/
const TYPE_MESSAGE =
'A type written as `import("...").Name` hides a dependency that belongs ' +
"in the file's import list. Declare it at the top with `import type` — " +
"TypeScript erases the declaration, so nothing about the build or the " +
"load order changes. For a whole module, `import type * as name from " +
'"..."` and then `typeof name`. See docs/development/imports.md.';
const MODULE_MESSAGE =
'A dynamic `import("...")` naming its module with a plain string has a ' +
"top-of-file equivalent, so write it there. Where the load is deferred on " +
"purpose — an optional dependency, a module that costs something at " +
"import time, or one instance per call — keep it and say which, with " +
"`// deno-lint-ignore cf-imports/no-inline-module-import -- `. " +
"See docs/development/imports.md.";
/** The shape these rules read off a node, on top of the type tag. */
interface ImportNode {
readonly type: string;
readonly source?: { readonly type: string; readonly value?: unknown };
}
export default {
name: "cf-imports",
rules: {
"no-inline-type-import": {
create(context) {
return {
TSImportType(node) {
context.report({ node, message: TYPE_MESSAGE });
},
};
},
},
"no-inline-module-import": {
create(context) {
return {
ImportExpression(node) {
// A computed specifier — a template literal, a variable, a URL
// built at run time — names a module no import declaration can
// name, so it is left alone. The value is checked as well as the
// node type, because a number, a regular expression and `null` are
// all `Literal` too, and none of them is a module specifier an
// import declaration could carry.
const { source } = node as unknown as ImportNode;
if (source?.type !== "Literal") return;
if (typeof source.value !== "string") return;
context.report({ node, message: MODULE_MESSAGE });
},
};
},
},
},
} satisfies Deno.lint.Plugin;