Skip to content

Commit

Permalink
feat: move assignment expression transformations behind flag (closes #2)
Browse files Browse the repository at this point in the history
  • Loading branch information
Xunnamius committed May 1, 2021
1 parent a2316d1 commit 2b982a0
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 54 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@

# babel-plugin-explicit-exports-references

Transforms all internal references to a module's exports such that each reference starts with `module.exports` instead of directly referencing an internal name. This enables easy mocking of specific (exported) functions in Jest with Babel/TypeScript, even when the mocked functions call each other in the same module.
Transforms all internal references to a module's exports such that each
reference starts with `module.exports` instead of directly referencing an
internal name. This enables easy mocking of specific (exported) functions in
Jest with Babel/TypeScript, even when the mocked functions call each other in
the same module.

(more description incoming)

Expand Down Expand Up @@ -104,9 +108,6 @@ information.
https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free
[exports-main-key]:
https://github.com/nodejs/node/blob/8d8e06a345043bec787e904edc9a2f5c5e9c275f/doc/api/packages.md#package-entry-points
[tree-shaking]: https://webpack.js.org/guides/tree-shaking
[local-pkg]:
https://github.com/nodejs/node/blob/8d8e06a345043bec787e904edc9a2f5c5e9c275f/doc/api/packages.md#type
[choose-new-issue]:
https://github.com/Xunnamius/babel-plugin-explicit-exports-references/issues/new/choose
[pr-compare]:
Expand Down
116 changes: 75 additions & 41 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ let globalScope: NodePath['scope'];

function updateExportRefs(
path: NodePath<util.Identifier>,
mode: 'named' | 'default'
mode: 'named' | 'default',
transformAssignExpr: boolean
): void;
function updateExportRefs(
path: { from: NodePath<util.Identifier>; to: string },
mode: 'named' | 'default'
mode: 'named' | 'default',
transformAssignExpr: boolean
): void;
function updateExportRefs(
path: NodePath<util.Identifier> | { from: NodePath<util.Identifier>; to: string },
mode: 'named' | 'default'
mode: 'named' | 'default',
transformAssignExpr: boolean
): void {
debug(`mode: ${mode}`);

// @ts-expect-error: need to discriminate between input types
const idPath = (path.isIdentifier?.() ? path : path.from) as NodePath<util.Identifier>;
const localName = idPath.node.name;
Expand All @@ -32,12 +33,18 @@ function updateExportRefs(
...(globalBinding?.constantViolations || [])
];

debug(
`updating ${refPaths?.length || 0} references to ${mode} export "${localName}"` +
(exportedName != localName ? ` (exported as "${exportedName}")` : '')
);
const numRefs = refPaths?.length || 0;
const dbg = debug.extend(`mode-${mode}:updating`);

if (numRefs) {
dbg(
`potentially updating ${numRefs} references to ${mode} export "${localName}"` +
(exportedName != localName ? ` (exported as "${exportedName}")` : '')
);
} else dbg('no references to update');

refPaths?.forEach((refPath) => {
refPaths?.forEach((refPath, ndx) => {
const dbg2 = dbg.extend(`ref-${exportedName}-${(ndx + 1).toString()}`);
if (
!!refPath.find(
(path) =>
Expand All @@ -46,87 +53,113 @@ function updateExportRefs(
path.isExportDefaultSpecifier()
)
) {
debug('(an export specifier reference was skipped)');
dbg2('reference skipped: part of an export specifier');
return;
}

if (!!refPath.find((path) => path.isTSType())) {
debug('(an TypeScript type reference was skipped)');
dbg2('reference skipped: TypeScript type reference');
return;
}

const wasReplaced = !!(refPath.isIdentifier()
? refPath
: refPath.isAssignmentExpression()
? refPath.get('left')
: undefined
)?.replaceWith(
template.expression.ast`module.exports.${mode == 'default' ? mode : exportedName}`
);

if (!wasReplaced) debug(`(unsupported reference type "${refPath.type}" was skipped)`);
if (refPath.isIdentifier()) {
dbg2('transforming type "identifier"');
refPath.replaceWith(
template.expression.ast`module.exports.${mode == 'default' ? mode : exportedName}`
);
} else if (transformAssignExpr && refPath.isAssignmentExpression()) {
dbg2('transforming type "assignment expression"');
refPath
.get('left')
// TODO: needs to be more resilient, but we'll repeat this here for now
.replaceWith(
template.expression.ast`module.exports.${
mode == 'default' ? mode : exportedName
}`
);
} else dbg2(`reference skipped: unsupported type "${refPath.type}"`);
});
}

export default function (): PluginObj<PluginPass> {
export default function (): PluginObj<
PluginPass & { opts: { transformAssignExpr: boolean } }
> {
return {
name: 'explicit-exports-references',
visitor: {
Program(programPath) {
globalScope = programPath.scope;
},
ExportDefaultDeclaration(exportPath) {
ExportDefaultDeclaration(exportPath, state) {
const declaration = exportPath.get('declaration');
debug(`encountered default export`);
const transformAssignExpr = state.opts.transformAssignExpr;
const dbg = debug.extend('mode-default');

debug(`encountered default export declaration`);

if (declaration.isFunctionDeclaration() || declaration.isClassDeclaration()) {
const id = declaration.get('id') as NodePath<util.Identifier>;
if (id?.node?.name) updateExportRefs(id, 'default');
else debug('default declaration is anonymous, ignoring');
} else debug('(ignored)');
if (id?.node?.name) updateExportRefs(id, 'default', transformAssignExpr);
else dbg('default declaration is anonymous, ignored');
} else dbg('default declaration not function or class, ignored');
},
ExportNamedDeclaration(exportPath) {
ExportNamedDeclaration(exportPath, state) {
const declaration = exportPath.get('declaration');
const specifiers = exportPath.get('specifiers');
const transformAssignExpr = state.opts.transformAssignExpr;
const dbg = debug.extend('mode-named');

if (!declaration.node && !specifiers.length) {
debug('ignored empty named export declaration');
dbg('ignored empty named export declaration');
return;
}

debug(`encountered named export`);
debug(`encountered named export node`);
dbg(`processing declaration`);

if (declaration.node) {
if (declaration.isFunctionDeclaration() || declaration.isClassDeclaration()) {
updateExportRefs(declaration.get('id') as NodePath<util.Identifier>, 'named');
updateExportRefs(
declaration.get('id') as NodePath<util.Identifier>,
'named',
transformAssignExpr
);
} else if (declaration.isVariableDeclaration()) {
declaration.get('declarations').forEach((declarator) => {
const id = declarator.get('id');
if (id.isIdentifier()) updateExportRefs(id, 'named');
if (id.isIdentifier()) updateExportRefs(id, 'named', transformAssignExpr);
else if (id.isObjectPattern()) {
id.get('properties').forEach((propPath) => {
if (propPath.isObjectProperty()) {
const propId = propPath.get('value');
if (propId.isIdentifier()) updateExportRefs(propId, 'named');
if (propId.isIdentifier())
updateExportRefs(propId, 'named', transformAssignExpr);
} else if (propPath.isRestElement()) {
const arg = propPath.get('argument');
if (arg.isIdentifier()) updateExportRefs(arg, 'named');
if (arg.isIdentifier())
updateExportRefs(arg, 'named', transformAssignExpr);
}
});
}
});
} else debug('(ignored)');
} else {
dbg(
'named declaration is not a function, class, or variable declaration; ignored'
);
}
}

specifiers.length && dbg(`processing ${specifiers.length} specifiers`);

// ? Later exports take precedence over earlier ones
specifiers.forEach((specifier) => {
if (!specifier.isExportSpecifier()) {
debug(`(ignored export specifier type "${specifier.type}")`);
dbg(`ignored export specifier type "${specifier.type}"`);
} else {
const local = specifier.get('local');
const exported = specifier.get('exported');

debug(`encountered specifier "${local} as ${exported}"`);
dbg(`encountered specifier "${local} as ${exported}"`);

if (exported.isIdentifier()) {
const exportedName = exported.node.name;
Expand All @@ -135,11 +168,12 @@ export default function (): PluginObj<PluginPass> {
from: local,
to: exportedName
},
exportedName == 'default' ? 'default' : 'named'
exportedName == 'default' ? 'default' : 'named',
transformAssignExpr
);
} else {
debug(
'(ignored export specifier because module string names are not supported)'
dbg(
'ignored export specifier because module string names are not supported'
);
}
}
Expand Down
70 changes: 70 additions & 0 deletions test/__fixtures__/transforms-assignment-expressions/code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
function internalfn1() {
fn1();
void Promise.all([1].map((_) => [fn2]).map((a) => a[0]()));
}

export function fn1() {
global.console.log('hello, world!');
}

export async function fn2() {
fn1();
internalfn1();
}

export async function fn3() {
const f = fn1;
await fn2();
f();
internalfn1();
}

internalfn1();
void fn3();

export let var1: string;
var1 = 'hello, world!';
var1 = 'goodbye, world!';
export let var2 = 2;
var2 = 3;
export let var3: boolean, var4: boolean;
export let var5: boolean | string, var6: number;
var5 = true;
var5 = var1;
var6 = 6;
var6 = var2;

export const var7 = 7,
var8 = 8;
export function fn4() {
return var7 + var8;
}

let var9 = 9;
var9 += 1;
const var10 = var9;

export class Class1 {
val() {
return var9;
}
}

export class Class2 {
val() {
return var10 + var6;
}
}

class Class3 {
val() {
return new Class1().val() + new Class2().val() + new Class3().life();
}

life() {
return 42;
}
}

new Class3();
new Class2();
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"fixtureOutputExt": ".mjs",
"transformAssignExpr": true
}
66 changes: 66 additions & 0 deletions test/__fixtures__/transforms-assignment-expressions/output.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
function internalfn1() {
module.exports.fn1();
void Promise.all([1].map((_) => [module.exports.fn2]).map((a) => a[0]()));
}

export function fn1() {
global.console.log('hello, world!');
}
export async function fn2() {
module.exports.fn1();
internalfn1();
}
export async function fn3() {
const f = module.exports.fn1;
await module.exports.fn2();
f();
internalfn1();
}
internalfn1();
void module.exports.fn3();
export let var1;
module.exports.var1 = 'hello, world!';
module.exports.var1 = 'goodbye, world!';
export let var2 = 2;
module.exports.var2 = 3;
export let var3, var4;
export let var5, var6;
module.exports.var5 = true;
module.exports.var5 = module.exports.var1;
module.exports.var6 = 6;
module.exports.var6 = module.exports.var2;
export const var7 = 7,
var8 = 8;
export function fn4() {
return module.exports.var7 + module.exports.var8;
}
let var9 = 9;
var9 += 1;
const var10 = var9;
export class Class1 {
val() {
return var9;
}
}
export class Class2 {
val() {
return var10 + module.exports.var6;
}
}

class Class3 {
val() {
return (
new module.exports.Class1().val() +
new module.exports.Class2().val() +
new Class3().life()
);
}

life() {
return 42;
}
}

new Class3();
new module.exports.Class2();
14 changes: 7 additions & 7 deletions test/__fixtures__/transforms-primitive-exports/output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ export async function fn3() {
internalfn1();
void module.exports.fn3();
export let var1;
module.exports.var1 = 'hello, world!';
module.exports.var1 = 'goodbye, world!';
var1 = 'hello, world!';
var1 = 'goodbye, world!';
export let var2 = 2;
module.exports.var2 = 3;
var2 = 3;
export let var3, var4;
export let var5, var6;
module.exports.var5 = true;
module.exports.var5 = module.exports.var1;
module.exports.var6 = 6;
module.exports.var6 = module.exports.var2;
var5 = true;
var5 = module.exports.var1;
var6 = 6;
var6 = module.exports.var2;
export const var7 = 7,
var8 = 8;
export function fn4() {
Expand Down
Loading

0 comments on commit 2b982a0

Please sign in to comment.