Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rename destructuring #63

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/docs/src/concepts/unminify.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ if (x) {
1 // [!code ++]
```

## rename-destructuring

```js
const { gql: t, dispatchers: o, listener: i = noop } = n; // [!code --]
o.delete(t, i); // [!code --]
const { gql, dispatchers, listener = noop } = n; // [!code ++]
dispatchers.delete(gql, listener); // [!code ++]
```

## sequence

```js
Expand Down
24 changes: 19 additions & 5 deletions packages/webcrack/src/ast-utils/rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,25 @@ import * as m from '@codemod/matchers';
import { codePreview } from './generator';

export function renameFast(binding: Binding, newName: string): void {
binding.scope.rename(newName);

binding.referencePaths.forEach((ref) => {
if (!ref.isIdentifier()) {
throw new Error(
`Unexpected reference (${ref.type}): ${codePreview(ref.node)}`,
);
}

// To avoid conflicts with other bindings of the same name
if (ref.scope.hasBinding(newName)) ref.scope.rename(newName);
ref.scope.rename(newName);
ref.node.name = newName;
});

// Also update assignments
const patternMatcher = m.assignmentExpression(
'=',
m.or(m.arrayPattern(), m.objectPattern()),
);
binding.constantViolations.forEach((ref) => {
// To avoid conflicts with other bindings of the same name
if (ref.scope.hasBinding(newName)) ref.scope.rename(newName);
ref.scope.rename(newName);

if (ref.isAssignmentExpression() && t.isIdentifier(ref.node.left)) {
ref.node.left.name = newName;
Expand Down Expand Up @@ -56,6 +55,21 @@ export function renameFast(binding: Binding, newName: string): void {
binding.identifier.name = newName;
}

/**
* @returns the new name, unless it is invalid or conflicts with another binding,
* in which case it will generate a similar name instead.
*/
export function generateUid(binding: Binding, newName: string): string {
const hasConflicts = () =>
binding.scope.hasBinding(newName) ||
binding.referencePaths.some((ref) => ref.scope.hasBinding(newName)) ||
binding.constantViolations.some((ref) => ref.scope.hasBinding(newName));

return t.isValidIdentifier(newName) && !hasConflicts()
? newName
: binding.scope.generateUid(newName);
}

export function renameParameters(
path: NodePath<t.Function>,
newNames: string[],
Expand Down
31 changes: 31 additions & 0 deletions packages/webcrack/src/ast-utils/test/rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ describe('rename variable', () => {
`);
});

test('conflict with existing binding 2', () => {
const ast = parse('let a = 1; let b = 2;');
traverse(ast, {
Program(path) {
const binding = path.scope.getBinding('a')!;
renameFast(binding, 'b');
},
});
expect(ast).toMatchInlineSnapshot(`
let b = 1;
let _b = 2;
`);
});

test('duplicate function binding', () => {
const ast = parse('var a; function a() {}');
traverse(ast, {
Expand Down Expand Up @@ -62,6 +76,23 @@ describe('rename variable', () => {
for (b in []);
`);
});

test.todo('unsupported node types', () => {
const ast = parse(
`
export var A = 1;
<A />;
`,
{ sourceType: 'module', plugins: ['jsx'] },
);
traverse(ast, {
Program(path) {
const binding = path.scope.getBinding('A')!;
renameFast(binding, 'B');
},
});
expect(ast).toMatchInlineSnapshot();
});
});

describe('rename parameters', () => {
Expand Down
100 changes: 100 additions & 0 deletions packages/webcrack/src/unminify/test/rename-destructuring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test } from 'vitest';
import { testTransform } from '../../../test';
import { renameDestructuring } from '../transforms';

const expectJS = testTransform(renameDestructuring);

test('rename object destructuring', () =>
expectJS(`
const {
gql: t,
dispatchers: o,
listener: i = noop
} = n;
o.delete(t, i);
`).toMatchInlineSnapshot(`
const {
gql,
dispatchers,
listener = noop
} = n;
dispatchers.delete(gql, listener);
`));

test('ignore same key and alias', () =>
expectJS(`
const {
gql,
dispatchers: dispatchers,
} = n;
`).toMatchInlineSnapshot(`
const {
gql,
dispatchers
} = n;
`));

test('rename object destructuring with conflict', () =>
expectJS(`
const gql = 1;
const {
gql: t,
dispatchers: o,
listener: i
} = n;

function foo({
gql: t,
dispatchers: o,
listener: i
}) {
o.delete(t, i);
}
`).toMatchInlineSnapshot(`
const gql = 1;
const {
gql: _gql,
dispatchers,
listener
} = n;
function foo({
gql: _gql2,
dispatchers: _dispatchers,
listener: _listener
}) {
_dispatchers.delete(_gql2, _listener);
}
`));

test('rename object destructuring with global variable conflict', () =>
expectJS(`
const {
Object: t,
} = n;
Object.keys(t);
`).toMatchInlineSnapshot(`
const {
Object: _Object
} = n;
Object.keys(_Object);
`));

test('rename object destructuring with reserved identifier', () =>
expectJS(`
const { delete: t } = n;
t();
`).toMatchInlineSnapshot(`
const {
delete: _delete
} = n;
_delete();
`));

test('rename import', () =>
expectJS(`
import { render as a } from "preact";
a();
`).toMatchInlineSnapshot(`
import { render } from "preact";
render();
`));
1 change: 1 addition & 0 deletions packages/webcrack/src/unminify/transforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { default as mergeElseIf } from './merge-else-if';
export { default as mergeStrings } from './merge-strings';
export { default as numberExpressions } from './number-expressions';
export { default as rawLiterals } from './raw-literals';
export { default as renameDestructuring } from './rename-destructuring';
export { default as sequence } from './sequence';
export { default as splitForLoopVars } from './split-for-loop-vars';
export { default as splitVariableDeclarations } from './split-variable-declarations';
Expand Down
54 changes: 54 additions & 0 deletions packages/webcrack/src/unminify/transforms/rename-destructuring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as t from '@babel/types';
import * as m from '@codemod/matchers';
import { generateUid, renameFast, type Transform } from '../../ast-utils';

export default {
name: 'rename-destructuring',
tags: ['safe'],
scope: true,
visitor() {
const key = m.capture(m.anyString());
const alias = m.capture(m.anyString());
const matcher = m.objectProperty(
m.identifier(key),
m.or(m.identifier(alias), m.assignmentPattern(m.identifier(alias))),
);

return {
ObjectPattern: {
exit(path) {
for (const property of path.node.properties) {
if (!matcher.match(property)) continue;

if (key.current === alias.current) {
property.shorthand = true;
} else {
const binding = path.scope.getBinding(alias.current!);
if (!binding) continue;

const newName = generateUid(binding, key.current!);
renameFast(binding, newName);
property.shorthand = key.current === newName;
this.changes++;
}
}
},
},
ImportSpecifier: {
exit(path) {
if (
t.isIdentifier(path.node.imported) &&
path.node.imported.name !== path.node.local.name
) {
const binding = path.scope.getBinding(path.node.local.name);
if (!binding) return;

const newName = generateUid(binding, path.node.imported.name);
renameFast(binding, newName);
this.changes++;
}
},
},
};
},
} satisfies Transform;
Loading