sdxc

[ Content & formats ]

@sdxc/codemod

Source-to-source edits of JavaScript and TypeScript imports and declarations, changing only the edited ranges

npm add @sdxc/codemod
pnpm add @sdxc/codemod
yarn add @sdxc/codemod
bun add @sdxc/codemod
Installs with
@sdxc/result

Source-to-source edits of JavaScript and TypeScript imports and declarations, changing only the edited ranges.

Installation

npm add @sdxc/codemod

Every function answers a Result from @sdxc/result, which installs alongside this package. There is no other runtime dependency: the scanner is plain JavaScript and runs on Node, Bun, Deno or a Worker.

Usage

Find every module a file references

import { findModuleReferences } from "@sdxc/codemod";
import { isFailure } from "@sdxc/result";

let references = findModuleReferences("page.tsx", source, { calls: ["vi.mock"] });
if (isFailure(references)) throw references.error; // "page.tsx:12:8: string is never closed"

for (let reference of references.data) {
	reference.kind; // "import" | "export" | "dynamic" | "call"
	reference.specifier; // "./button.js"
	reference.names; // [{ imported: "Button", local: "Button", typeOnly: false, range }]
}

The file name's extension decides what the scanner reads: .js, .mjs, .cjs and .jsx as JavaScript with JSX, .ts, .mts and .cts as TypeScript, and .tsx as TypeScript with JSX. A < where an expression begins is an element in every JavaScript file, so a .js component reads the same as a .jsx one, and a < b stays a comparison. Strings, template literals, regular expressions, comments and JSX text are tokens of their own, so <p>Don't import "./x.js"</p> names no module.

Rewrite specifiers

import { rewriteSpecifiers } from "@sdxc/codemod";

let rewritten = rewriteSpecifiers("dialog.tsx", source, (reference) =>
	reference.specifier.startsWith("@acme/") ? reference.specifier.replace("@acme/", "acme/") : null,
);
// success({ source: "…", changed: true, unhandled: [] })

Only the string literals that change are replaced, each in its original quote character.

Move imports to another module

import { moveImports } from "@sdxc/codemod";

let moved = moveImports("page.tsx", 'import { Button, Dialog } from "acme/ui";\n', {
	from: "acme/ui",
	names: ["Button"],
	to: "~/components/ui/button.js",
});
// import { Dialog } from "acme/ui";
// import { Button } from "~/components/ui/button.js";

Name "default" to move a default import and "*" to move import * as ui, export * and export * as ui.

Add to a declaration object and to a sequence of calls

import { addProperty, insertAfterLast } from "@sdxc/codemod";

let declared = addProperty("index.ts", source, {
	call: "jobs",
	path: ["trials"],
	key: "expire",
	value: "job({ input: s.object({}) })",
});

let mapped = insertAfterLast("dispatcher.ts", source, {
	call: "dispatcher.map",
	statement: 'dispatcher.map(jobs.trials.expire, () => import("./jobs/expire.js"));',
	fallbackAfter: "createJobDispatcher",
});

API

findModuleReferences(filename: string, source: string, options?: FindOptions): Result<ModuleReference[], CodemodError>

Every static import and side-effect import, every export … from, every import() whose argument is a string literal, and the first argument of each call whose callee is listed in options.calls (dotted paths such as vi.mock, or require), in source order. These are the specifiers a module loader resolves: imports and re-exports at the top level, where ECMAScript allows them, and import() in expressions. TypeScript import types (typeof import("…"), import("…").Name) and import x = require("…") belong to the type checker and to CommonJS, and stay out of the list unless options.importTypes is set, which reports them as dynamic references with typeOnly set, the way a declaration file names its dependencies.

rewriteSpecifiers(filename, source, map, options?): Result<Edit, CodemodError>

Replaces the specifier of every reference map answers a string for; null leaves it untouched. options.calls adds listed calls to the references map sees.

moveImports(filename, source, options: MoveOptions): Result<Edit, CodemodError>

Moves the imports and re-exports of options.names from options.from to options.to. A name is matched as the module exports it, whatever alias a declaration gives it: a named binding by its name, a default binding by "default", and a namespace (import * as ui, export * as ui) or a bare export * by "*".

  • A declaration whose bindings all move has its specifier replaced in place.

  • Otherwise the declaration keeps the rest, and the moved bindings go to options.to, keeping aliases, type modifiers, import type, the quote character and the semicolon style.

  • Every moved binding of one kind (imports or re-exports, type-only or not) lands in one declaration of options.to: an existing one, the first one repointed in place, or one new declaration. import { A } from "x" and import { B } from "x" both moving become import { A, B } from "y", and a default joins the names (import Ui, { A } from "y"). A namespace, a type-only default and a second default keep declarations of their own, and import type stays apart from import.

  • A namespace import or export * of options.from that stays while other names move reaches those names through the whole module. It is returned in unhandled and left as it is.

addProperty(filename, source, options: AddPropertyOptions): Result<Edit, CodemodError>

Appends key: value to the object literal passed as the first argument of the first call to options.call. options.path walks nested object literals by key; a value that is a call, such as route("/prefix", { … }), is walked through its object argument, and a missing level is created. The property follows the last one with its siblings' indentation and trailing-comma style. An existing key fails with duplicate-key.

insertAfterLast(filename, source, options: InsertOptions): Result<Edit, CodemodError>

Places options.statement on a new line after the last statement that calls options.call, at that statement's indentation. With no such call, it follows the variable declaration whose initializer calls options.fallbackAfter; with neither, it fails with not-found.

value and statement are source text: every line after the first is indented to match the surrounding code, and nothing else about them changes. Run the project's formatter afterwards for anything beyond indentation.

Edit

{ source, changed, unhandled }: the edited text (the input itself when nothing changed), and for moveImports the namespace imports and export * declarations it left on the old module. Every edit scans its own output again and fails with invalid-output instead of returning source that does not scan.

CodemodError

The failure every function reports. Input that ends inside a string, template literal, regular expression, comment or JSX element, unbalanced brackets, or an import declaration outside the grammar fail before any edit is computed. The fields are:

  • code: "unsupported-file", "unterminated", "unbalanced", "unexpected-character", "invalid-declaration", "not-found", "duplicate-key" or "invalid-output".

  • filename: the name the caller passed.

  • offset: the UTF-16 offset the failure concerns, or null; message carries it as line:column.

Types

ModuleReference

{ kind, specifier, typeOnly, names, range, specifierRange, callee, braces }. range covers the whole declaration (its ; included) or call; specifierRange covers the string literal, quotes included; braces covers the { … } name list when there is one. Ranges are UTF-16 offsets, as String.prototype.slice reads them.

BoundName

{ imported, local, typeOnly, range }. imported is "default" for a default import and "*" for a namespace; for a re-export, local is the exported name.

Patterns

Pattern: Point a project at its own copy of a component

Move the component's import in every file, and report the imports that need a hand edit.

import { readFile, writeFile } from "node:fs/promises";

import { moveImports } from "@sdxc/codemod";
import { isFailure } from "@sdxc/result";

async function adopt(files: string[], name: string, to: string) {
	for (let file of files) {
		let source = await readFile(file, "utf8");
		let edit = moveImports(file, source, { from: "acme/ui", names: [name], to });
		if (isFailure(edit)) {
			console.warn(`Change the import of ${name} in ${file} by hand: ${edit.error.message}`);
			continue;
		}
		for (let reference of edit.data.unhandled) {
			console.warn(`${file}: ${source.slice(reference.range.start, reference.range.end)}`);
		}
		if (edit.data.changed) await writeFile(file, edit.data.source);
	}
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.