Skip to content
Merged
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
45 changes: 45 additions & 0 deletions src/codemods/__snapshots__/spread-syntax.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`spread-syntax > Object.assign() > should not change Object.assign with non-empty first argument 1`] = `
"const mutated = Object.assign(target, source);
"
`;

exports[`spread-syntax > Object.assign() > should replace Object.assign({}, ...) with spread syntax 1`] = `
"const merged = {...obj1, ...obj2};
const triple = {...a, ...b, ...c};
"
`;

exports[`spread-syntax > array.concat() > should not change code without concat 1`] = `
"const arr = [1, 2, 3];
const pushed = arr.push(4);
"
`;

exports[`spread-syntax > array.concat() > should replace concat with array literal 1`] = `
"const result = [...arr, ...[1, 2, 3]];
"
`;

exports[`spread-syntax > array.concat() > should replace concat with spread syntax 1`] = `
"const combined = [...arr, ...other];
const multi = [...arr, ...a, ...b, ...c];
"
`;

exports[`spread-syntax > function.apply() > should not change apply with context object 1`] = `
"const result = fn.apply(context, args);
"
`;

exports[`spread-syntax > function.apply() > should replace apply(null, args) with spread syntax 1`] = `
"const result = fn(...args);
const max = Math.max(...numbers);
"
`;

exports[`spread-syntax > function.apply() > should replace apply(undefined, args) with spread syntax 1`] = `
"const result = fn(...args);
"
`;
108 changes: 108 additions & 0 deletions src/codemods/spread-syntax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {describe, it, expect} from 'vitest';
import {codemod} from './spread-syntax.js';

describe('spread-syntax', () => {
describe('array.concat()', () => {
it('should replace concat with spread syntax', () => {
const source = `
const combined = arr.concat(other);
const multi = arr.concat(a, b, c);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});

it('should replace concat with array literal', () => {
const source = `
const result = arr.concat([1, 2, 3]);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});

it('should not change code without concat', () => {
const source = `
const arr = [1, 2, 3];
const pushed = arr.push(4);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});
});

describe('Object.assign()', () => {
it('should replace Object.assign({}, ...) with spread syntax', () => {
const source = `
const merged = Object.assign({}, obj1, obj2);
const triple = Object.assign({}, a, b, c);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});

it('should not change Object.assign with non-empty first argument', () => {
const source = `
const mutated = Object.assign(target, source);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});
});

describe('function.apply()', () => {
it('should replace apply(null, args) with spread syntax', () => {
const source = `
const result = fn.apply(null, args);
const max = Math.max.apply(null, numbers);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});

it('should replace apply(undefined, args) with spread syntax', () => {
const source = `
const result = fn.apply(undefined, args);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});

it('should not change apply with context object', () => {
const source = `
const result = fn.apply(context, args);
`;
const result = codemod.apply({source});
expect(result).toMatchSnapshot();
});
});

describe('test', () => {
it('should detect array.concat()', () => {
const source = `const result = arr.concat(other);`;
expect(codemod.test({source})).toBe(true);
});

it('should detect Object.assign({}, ...)', () => {
const source = `const result = Object.assign({}, obj);`;
expect(codemod.test({source})).toBe(true);
});

it('should detect function.apply(null, args)', () => {
const source = `const result = fn.apply(null, args);`;
expect(codemod.test({source})).toBe(true);
});

it('should detect function.apply(undefined, args)', () => {
const source = `const result = fn.apply(undefined, args);`;
expect(codemod.test({source})).toBe(true);
});

it('should not detect when no patterns match', () => {
const source = `
const arr = [1, 2, 3];
const obj = {a: 1};
`;
expect(codemod.test({source})).toBe(false);
});
});
});
88 changes: 88 additions & 0 deletions src/codemods/spread-syntax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi';
import type {Options, CodeMod} from '../shared.js';

const arrayConcatRule: NapiConfig = {
rule: {
pattern: '$ARRAY.concat($$$ARGS)'
}
};

const objectAssignRule: NapiConfig = {
rule: {
pattern: 'Object.assign({}, $$$ARGS)'
}
};

const functionApplyRule: NapiConfig = {
rule: {
any: [
{
pattern: '$FN.apply(null, $ARGS)'
},
{
pattern: '$FN.apply(undefined, $ARGS)'
}
]
}
};

export const codemod: CodeMod = {
test(options: Options): boolean {
const ast = parse(Lang.TypeScript, options.source);
const root = ast.root();

return (
root.has(arrayConcatRule) ||
root.has(objectAssignRule) ||
root.has(functionApplyRule)
);
},
apply(options: Options): string {
const ast = parse(Lang.TypeScript, options.source);
const root = ast.root();
const edits: Edit[] = [];

const concatNodes = root.findAll(arrayConcatRule);
for (const node of concatNodes) {
const array = node.getMatch('ARRAY');
const args = node
.getMultipleMatches('ARGS')
.filter((arg) => arg.kind() !== ',');

if (array && args.length > 0) {
const spreadParts = [
array.text(),
...args.map((arg) => arg.text())
].map((part) => `...${part}`);
const replacement = `[${spreadParts.join(', ')}]`;
edits.push(node.replace(replacement));
}
}

const assignNodes = root.findAll(objectAssignRule);
for (const node of assignNodes) {
const args = node
.getMultipleMatches('ARGS')
.filter((arg) => arg.kind() !== ',');

if (args.length > 0) {
const spreadParts = args.map((arg) => `...${arg.text()}`);
const replacement = `{${spreadParts.join(', ')}}`;
edits.push(node.replace(replacement));
}
}

const applyNodes = root.findAll(functionApplyRule);
for (const node of applyNodes) {
const fn = node.getMatch('FN');
const args = node.getMatch('ARGS');

if (fn && args) {
const replacement = `${fn.text()}(...${args.text()})`;
edits.push(node.replace(replacement));
}
}

return root.commitEdits(edits);
}
};
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export {codemod as arrayToSorted} from './codemods/array-to-sorted.js';
export {codemod as arrayToSpliced} from './codemods/array-to-spliced.js';
export {codemod as nullishCoalescing} from './codemods/nullish-coalescing.js';
export {codemod as postcssSignFunctions} from './codemods/postcss-sign-functions.js';
export {codemod as spreadSyntax} from './codemods/spread-syntax.js';
export {codemod as stringIncludes} from './codemods/string-includes.js';