import { toTree, print } from 'ember-estree';
import { glob, readFile, writeFile } from 'node:fs/promises';
const REMOVE_IMPORT = 'REMOVE_IMPORT';
const RENAME = 'RENAME';
const MAP = {
'ember-truth-helpers': {
eq: REMOVE_IMPORT,
notEq: [[RENAME, 'neq'], REMOVE_IMPORT],
not: REMOVE_IMPORT,
and: REMOVE_IMPORT,
or: REMOVE_IMPORT,
gt: REMOVE_IMPORT,
gte: REMOVE_IMPORT,
lt: REMOVE_IMPORT,
lte: REMOVE_IMPORT,
},
'ember-truth-helpers/helpers/not-eq': { default: [[RENAME, 'neq'], REMOVE_IMPORT] },
'ember-truth-helpers/helpers/not': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/eq': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/and': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/or': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/gt': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/gte': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/lt': { default: REMOVE_IMPORT },
'ember-truth-helpers/helpers/lte': { default: REMOVE_IMPORT },
'@ember/helper': {
array: [
// [RENAME, 'Array'],
REMOVE_IMPORT],
hash: [
//[RENAME, 'Object'],
REMOVE_IMPORT],
fn: REMOVE_IMPORT,
},
'ember-element-helper': {
'element': REMOVE_IMPORT,
// Bare default import can't be derived from the path — name it explicitly
// so an aliased local (e.g. `import element_ from 'ember-element-helper'`)
// has its usages renamed to the `element` keyword.
default: [[RENAME, 'element'], REMOVE_IMPORT],
},
'ember-element-helper/helpers/element': {
default: REMOVE_IMPORT,
},
'@ember/modifier': {
on: REMOVE_IMPORT,
},
'@ember/object/computed': legacyPrefix,
}
function updateImports(contents) {
/**
* localName -> newName, collected while walking import specifiers and
* applied to every usage (JS identifiers + Glimmer template paths).
* ImportDeclarations are visited before usages, so this is populated in time.
*/
let renames = new Map();
/**
* Span-based patches against the *original* source: `{ start, end, text }`.
* We only re-emit the ranges we actually change and splice them into the
* untouched original — so comments, formatting, and unrelated code are
* preserved byte-for-byte (the full-program `print()` reformatted everything).
*/
let edits = [];
toTree(contents, {
visitors: {
ImportDeclaration(node) {
let rule = MAP[node.source.value];
if (!rule) return;
// A rule is either a per-specifier object or a function of the
// imported name (wildcard rules like `legacyPrefix`).
let lookup = typeof rule === 'function' ? rule : (name) => rule[name];
let removed = new Set();
let changed = false;
for (let specifier of node.specifiers) {
// 'default' for `import X from ...`, otherwise the imported name.
let importedName =
specifier.type === 'ImportDefaultSpecifier'
? 'default'
: (specifier.imported?.name ?? specifier.imported?.value);
let value = lookup(importedName);
if (value == null) continue;
let actions = normalizeActions(value);
if (actions.some((a) => a.type === REMOVE_IMPORT)) {
// Removal wins — the binding disappears. Usages must point at the
// canonical keyword name: an explicit RENAME target if given,
// otherwise the imported/keyword name. This catches aliased locals
// (e.g. `import element_ from '.../helpers/element'`, used as
// `(element_ …)`) that would otherwise dangle as undefined.
let rename = actions.find((a) => a.type === RENAME);
let target = rename ? rename.to : canonicalName(specifier, node.source.value);
if (target && target !== specifier.local.name) {
renames.set(specifier.local.name, target);
}
removed.add(specifier);
changed = true;
continue;
}
// RENAME with no REMOVE_IMPORT: alias the local binding, keeping the
// imported name (`and` -> `and as legacyAnd`), and rename usages of
// the old local name. Skip when the local name wouldn't change.
let rename = actions.find((a) => a.type === RENAME);
if (rename && rename.to !== specifier.local.name) {
renames.set(specifier.local.name, rename.to);
specifier.local.name = rename.to;
changed = true;
}
}
if (!changed) return;
let [start, end] = span(node);
let remaining = node.specifiers.filter((s) => !removed.has(s));
if (remaining.length === 0) {
// Nothing left to import — drop the whole line, trailing newline too.
edits.push({ start, end: consumeTrailingNewline(contents, end), text: '' });
} else {
// Some specifiers survive — re-print just this declaration.
node.specifiers = remaining;
edits.push({ start, end, text: print(node) });
}
},
Identifier(node, path) {
let to = renames.get(node.name);
if (!to) return;
let parent = path.parent;
// Skip non-reference positions (property access / object keys).
if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) return;
if (parent?.type === 'Property' && parent.key === node && !parent.computed) return;
// Import/export specifier identifiers are handled at the declaration
// level; renaming them here would overlap that edit.
if (isImportExportSpecifier(parent?.type)) return;
let [start, end] = span(node);
edits.push({ start, end, text: to });
},
GlimmerPathExpression(node) {
let to = renames.get(node.original);
if (!to) return;
let [start, end] = span(node);
edits.push({ start, end, text: to });
},
},
});
return applyEdits(contents, edits);
}
/** Absolute [start, end) source offsets for a node (ESTree or Glimmer). */
function span(node) {
return [node.start ?? node.range?.[0], node.end ?? node.range?.[1]];
}
/**
* The canonical keyword name a removed helper import collapses to, so aliased
* locals don't leave dangling usages:
* - named import -> the imported name (`{ eq as e }` -> `eq`)
* - default import -> the `.../helpers/<name>` (or `/modifiers/`) path segment
* (`import element_ from '.../helpers/element'` -> `element`)
* Returns null when no canonical name can be determined.
*/
function canonicalName(specifier, source) {
if (specifier.type === 'ImportSpecifier') {
return specifier.imported?.name ?? specifier.imported?.value ?? null;
}
if (specifier.type === 'ImportDefaultSpecifier') {
let match = source.match(/\/(?:helpers|modifiers)\/([^/]+)$/);
return match ? match[1] : null;
}
return null;
}
function isImportExportSpecifier(type) {
return (
type === 'ImportSpecifier' ||
type === 'ImportDefaultSpecifier' ||
type === 'ImportNamespaceSpecifier' ||
type === 'ExportSpecifier'
);
}
/** Extend `end` past trailing horizontal whitespace and one line break. */
function consumeTrailingNewline(text, end) {
let i = end;
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
if (text[i] === '\r') i++;
if (text[i] === '\n') i++;
return i;
}
/** Splice edits into the source right-to-left so earlier offsets stay valid. */
function applyEdits(text, edits) {
let sorted = [...edits].sort((a, b) => b.start - a.start);
let out = text;
let lastStart = Infinity;
for (let { start, end, text: replacement } of sorted) {
if (end > lastStart) continue; // defensive: skip any overlapping edit
out = out.slice(0, start) + replacement + out.slice(end);
lastStart = start;
}
return out;
}
async function main() {
let [,,file] = process.argv;
if (file) {
await transformFile(file);
return;
}
for await (const file of glob('**/*.{gjs,gts}', { exclude: [
'node_modules/**',
'**/node_modules/**',
'**/blueprints/**',
] })) {
await waitForRoom();
let promise = transformFile(file);
addToBucket(promise);
}
// Let the final in-flight batch finish before the process exits.
await Promise.allSettled([...bucket]);
console.log(`Done: ${done} files`);
}
/*******************************************
*
* low-level (ish) helpers for the above
*
*******************************************/
/**
* Wildcard rule: alias every *named* import from a module with a `legacy`
* prefix (`and` -> `and as legacyAnd`) and update its usages. Default imports
* are left alone. A module's MAP entry may be this function instead of a
* per-specifier object.
*/
function legacyPrefix(importedName) {
if (importedName === 'default') return null;
return [RENAME, `legacy${importedName[0].toUpperCase()}${importedName.slice(1)}`];
}
/**
* Normalize a MAP value into an ordered list of actions.
*
* A value is one of:
* - REMOVE_IMPORT (a single action)
* - [RENAME, 'newName'] (a single action)
* - [[RENAME, 'newName'], REMOVE_IMPORT] (a list of actions, applied in order)
*/
function normalizeActions(value) {
let raw = Array.isArray(value) && value[0] === RENAME ? [value] : value;
let list = Array.isArray(raw) ? raw : [raw];
return list.map((action) => {
if (action === REMOVE_IMPORT) return { type: REMOVE_IMPORT };
if (Array.isArray(action) && action[0] === RENAME) {
return { type: RENAME, to: action[1] };
}
throw new Error(`Unknown action in MAP: ${JSON.stringify(action)}`);
});
}
const bucketSize = 100;
const flex = 50;
const bucket = new Set();
// Resolver for a pending waitForRoom(), if the loop is currently blocked.
let roomResolve = null;
let done = 0;
function addToBucket(promise) {
bucket.add(promise);
promise.finally(() => {
bucket.delete(promise);
// Wake the loop once we've drained back down to the low-water mark.
if (roomResolve && bucket.size <= flex) {
roomResolve();
roomResolve = null;
done++;
}
});
}
async function transformFile(filePath) {
let buffer = await readFile(filePath);
let content = buffer.toString();
try {
let output = updateImports(content);
if (output !== content) {
await writeFile(filePath, output)
}
} catch (e) {
console.error(filePath);
console.error(e);
process.exit(1);
}
}
function waitForRoom() {
if (bucket.size < bucketSize) {
console.log(`Finished ${done} of ¯\\_(ツ)_/¯`);
return;
}
console.log(`Bucket has ${bucket.size}... waiting`);
// Resolved by addToBucket()'s finally handler once the bucket drains to
// `flex`. Event-driven — no polling timer to leak or to keep the process
// alive after the work is done.
return new Promise((resolve) => {
roomResolve = resolve;
});
}
/*******************************************
*
* now that everything is defined: begin!
*
*******************************************/
main().catch((e) => {
console.error(e);
process.exit(1);
});
We can have an autofix as well.
we can autofix if there are no import specifier conflicts.
reference: one shot codemod, I did that doesn't cover all the cases, but was decent