Skip to content

Commit

Permalink
Add source code - AST Node interpolation
Browse files Browse the repository at this point in the history
This adds three tagged templates to the core API:

- jscodeshift.template.expression
- jscodeshift.template.statement
- jscodeshift.template.statements

Their purpose is to make it easier to replace nodes with more complex
constructs, without having the build the AST yourself.
For example, the following replaces all matching identifiers with a function
call:

```
let {expression} = jscodeshift.template;

// replaces all `bar` identifiers with the function call `foo(bar)`
jscodeshift(src)
  .find('Identifier', {name: 'bar'})
  .replaceWith(path => expression`foo(${path.node})`);
```

The difference between the three function is just how the provided
code snippet is parsed:

- `expression` returns an expression node
- `statement` returns a statement node
- `statements` returns an array of statement nodes

It is now also possible to replace a node with multiple nodes. This is
especially useful for replacing a single statement with multiple
statements (still have to experiment with expression -> multiple
expressions replacements).
  • Loading branch information
fkling committed Jul 16, 2015
1 parent 8b75dc7 commit f866c37
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"optional": ["runtime"]
}
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dependencies": {
"async": "^0.9.0",
"babel": "^5.6.14",
"babel-runtime": "^5.6.18",
"cli-color": "^0.3.2",
"esprima-fb": "^15001.1.0-dev-harmony-fb",
"lodash": "^3.5.0",
Expand All @@ -39,10 +40,15 @@
"temp": "^0.8.1"
},
"jest": {
"scriptPreprocessor": "./node_modules/babel-jest",
"scriptPreprocessor": "<rootDir>/node_modules/babel-jest",
"preprocessCachingDisabled": true,
"testPathDirs": [
"src",
"bin"
],
"unmockedModulePathPatterns": [
"babel-runtime",
"babel"
]
}
}
180 changes: 180 additions & 0 deletions src/__tests__/template-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*global jest, defined, it, expect, beforeEach*/

jest.autoMockOff();

let jscodeshift = require('../core');

describe('Templates', () => {
let statements;
let statement;
let expression;

beforeEach(() => {
({expression, statement, statements} = require('../template'));
});

it('interpolates expression nodes with source code', () => {

let input =
`var foo = bar;
if(bar) {
console.log(42);
}`;

let expected =
`var foo = alert(bar);
if(alert(bar)) {
console.log(42);
}`;

expect(
jscodeshift(input)
.find('Identifier', {name: 'bar'})
.replaceWith(path => expression`alert(${path.node})`)
.toSource()
).toEqual(expected);
});

it('interpolates statement nodes with source code', () => {
let input =
`for (var i = 0; i < 10; i++) {
console.log(i);
console.log(i / 2);
}`;

let expected =
`var i = 0;
while (i < 10) {
console.log(i);
console.log(i / 2);
i++;
}`;

expect(
jscodeshift(input)
.find('ForStatement')
.replaceWith(
({node}) => statements`
${node.init};
while (${node.test}) {
${node.body.body}
${node.update}
}`
)
.toSource()
).toEqual(expected);
});

describe('explode arrays', () => {

it('explodes arrays in function definitions', () => {
let input = `var foo = [a, b];`;
let expected = `var foo = function foo(a, b, c) {};`;

expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(
({node}) => expression`function foo(${node.elements}, c) {}`
)
.toSource()
)
.toEqual(expected);

expected = `var foo = function(a, b, c) {};`;

expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(
({node}) => expression`function(${node.elements}, c) {}`
)
.toSource()
)
.toEqual(expected);

expected = `var foo = (a, b) => {};`;

expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(
({node}) => expression`${node.elements} => {}`
)
.toSource()
)
.toEqual(expected);

expected = `var foo = (a, b, c) => {};`;

expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(
({node}) => expression`(${node.elements}, c) => {}`
)
.toSource()
)
.toEqual(expected);
});

it('explodes arrays in variable declarations', () => {
let input = `var foo = [a, b];`;
let expected = `var foo, a, b;`;
expect(
jscodeshift(input)
.find('VariableDeclaration')
// Need to use a block here because the arrow doesn't seem to be
// compiled with a line break after the return statement. Can't repro
// outside here though
.replaceWith(({node: {declarations: [node]}}) => {
return statement`var ${node.id}, ${node.init.elements};`;
})
.toSource()
)
.toEqual(expected);
});

it('explodes arrays in array expressions', () => {
let input = `var foo = [a, b];`;
let expected = `var foo = [a, b, c];`;
expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(({node}) => expression`[${node.elements}, c]`)
.toSource()
)
.toEqual(expected);
});

it('explodes arrays in object expressions', () => {
let input = `var foo = {a, b};`;
let expected = /var foo = \{\s*a,\s*b,\s*c: 42\s*};/;
expect(
jscodeshift(input)
.find('ObjectExpression')
.replaceWith(({node}) => expression`{${node.properties}, c: 42}`)
.toSource()
)
.toMatch(expected);
});

it('explodes arrays in call expressions', () => {
let input = `var foo = [a, b];`;
let expected = `var foo = bar(a, b, c);`;

expect(
jscodeshift(input)
.find('ArrayExpression')
.replaceWith(
({node}) => expression`bar(${node.elements}, c)`
)
.toSource()
)
.toEqual(expected);
});

});

});
7 changes: 5 additions & 2 deletions src/collections/Node.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,11 @@ var mutationMethods = {

return this.forEach(function(path, i) {
var newNode = replacement.call(path, path, i);
assert(Node.check(newNode), 'Replacement function returns a node');
path.replace(newNode);
if (Array.isArray(newNode)) {
path.replace(...newNode);
} else {
path.replace(newNode);
}
});
},

Expand Down
2 changes: 2 additions & 0 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var collections = require('./collections');
var esprima = require('esprima-fb');
var matchNode = require('./matchNode');
var recast = require('recast');
var template = require('./template');
var _ = require('lodash');

var Node = recast.types.namedTypes.Node;
Expand Down Expand Up @@ -97,6 +98,7 @@ _.assign(core, recast.types.builders);
core.registerMethods = Collection.registerMethods;
core.types = recast.types;
core.match = match;
core.template = template;

// add mappings and filters to function
core.filters = {};
Expand Down
100 changes: 100 additions & 0 deletions src/template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
let babel = require('babel');

function splice(arr, element, replacement) {
arr.splice(arr.indexOf(element), 1, ...replacement);
}

function getPlugin(varName, nodes) {
let counter = 0;

return function({Plugin, types: t}) {
return new Plugin('template', {
visitor: {
Identifier: {
exit: function(node, parent) {
if (node.name !== varName) {
return node;
}

let replacement = nodes[counter++];
if (Array.isArray(replacement)) {
// check whether we can explode arrays here
if (t.isFunction(parent) && parent.params.indexOf(node) > -1) {
// function foo(${bar}) {}
splice(parent.params, node, replacement);
} else if (t.isVariableDeclarator(parent)) {
// var foo = ${bar}, baz = 42;
splice(
this.parentPath.parentPath.node.declarations,
parent,
replacement
);
} else if (t.isArrayExpression(parent)) {
// var foo = [${bar}, baz];
splice(parent.elements, node, replacement);
} else if (t.isProperty(parent) && parent.shorthand) {
// var foo = {${bar}, baz: 42};
splice(
this.parentPath.parentPath.node.properties,
parent,
replacement
);
} else if (t.isCallExpression(parent) &&
parent.arguments.indexOf(node) > -1) {
// foo(${bar}, baz)
splice(parent.arguments, node, replacement);
} else if (t.isExpressionStatement(parent)) {
this.parentPath.replaceWithMultiple(replacement);
} else {
this.replaceWithMultiple(replacement);
}
} else if (t.isExpressionStatement(parent)) {
this.parentPath.replaceWith(replacement);
} else {
return replacement;
}
}
}
}
});
}
}

function replaceNodes(src, varName, nodes) {
return babel.transform(
src,
{
plugins: [getPlugin(varName, nodes)],
whitelist: [],
code: false,
}
).ast;
}

function getRandomVarName() {
return `$jscodeshift${Math.floor(Math.random() * 1000)}$`;
}

export function statements(template, ...nodes) {
template = [...template];
let varName = getRandomVarName();
let src = template.join(varName);
return replaceNodes(src, varName, nodes).program.body;
}

export function statement(template, ...nodes) {
return statements(template, ...nodes)[0];
}

export function expression(template, ...nodes) {
// wrap code in `(...)` to force evaluation as expression
template = [...template];
if (template.length > 1) {
template[0] = '(' + template[0];
template[template.length - 1] += ')';
} else if (template.length === 0) {
template[0] = '(' + template[0] + ')';
}

return statement(template, ...nodes).expression;
}

0 comments on commit f866c37

Please sign in to comment.