Tan poses the problem in his article Babel macros as follows:
Take optional chaining for example, before having the optional chaining operator ?., we had a few ways to write props?.user?.friends?.[0]?.friend, which is:
const firstFriend = props.user && props.user.friends && props.user.friends[0] ? props.user.friends[0].friend : null;
or with ternary
const firstFriend = props ? props.user ? props.user.friends ? props.user.friends ? props.user.friends[0] ? props.user.friends[0].friend : null : null : null : null : null;easy to write, easy to read, but with slightly more runtime overhead:
const firstFriend = idx(props, _ => _.user.friends[0].friend); function idx(input, accessor) { try { return accessor(input); } catch (e) { return null; } }
A good solution was to write a macro that transforms the code
idx(props, _ => _.user.friends[0].friend)
into the code props?.user?.friends?.[0]?.friend
or alternatively the ternary code.
The macro idx.macro
does exactly this. It is a macro that transforms the code idx(props, _ => _.user.friends[0].friend)
into the the corresponding ternary code.
See the examples in folder /src/tan-li.
Instead of installing the idx
plugin and its dependence like this:
➜ tan-li git:(main) ✗ npm install idx babel-plugin-idx
we install only the idx.macro
:
➜ tan-li git:(main) ✗ npm i idx.macro
And assuming we have added macros
to the plugins section of our babel config file:
➜ tan-li git:(main) ✗ cat babel.config.js
module.exports = {
plugins: ['babel-plugin-macros'],
};
Given the input code /src/tan-li/use-macro.mjs:
➜ babel-macros git:(main) ✗ cat src/tan-li/use-macro.mjs
import idx from 'idx.macro';
const friends_of_friends = idx(props, _ => _.user.friends[0].friends);
We can run babel to transform the input code::
➜ tan-li git:(main) ✗ npx babel use-macro.mjs
var _ref;
const friends_of_friends = // Output edited to make it more readable
(_ref = props) != null ?
(_ref = _ref.user) != null ?
(_ref = _ref.friends) != null ?
(_ref = _ref[0]) != null ?
_ref.friends
: _ref
: _ref
: _ref
: _ref;
Here is the source of the idx.macro:
const t = require('@babel/types');
const { createMacro } = require('babel-plugin-macros');
module.exports = createMacro(({ state, references }) => {
references.default.forEach(referencePath => {
if (referencePath.parentPath.type === 'CallExpression') {
idx_transform(referencePath.parentPath, state);
} else {
throw Error(
`idx.macro can only be used a function, and can not be passed around as an argument.`
);
}
});
});
The function createMacro
ensures your macro is only
called in the context of a babel transpilation and throws an
error with a helpful message if someone does not have
configured correctly babel-plugin-macros
The second argument you're passed to a visitor in a normal babel plugin is state
. This is the
state
property that is passed to the createMacro
function.
The object passed to the function createMacro
has also a field called babel
which is the babel-plugin-macros
module but is skipped in this example.
The way that babel-plugin-macros
determines whether to run a macro is based on the source string of the import
or require
statement. It must match this regex: /[./]macro(\.c?js)?$/
in our example:
import idx from 'idx.macro';
const friends_of_friends = idx(props, _ => _.user.friends[0].friends);
tells babel-plugin-macros
to
- Look for the
idx.macro
import or require and since the name matches then - Collect in a
reference
list all thepath
references to nodesIdentifier
with nameidx
in the AST - Call the macro exported by the module
idx.macro
with thestate
,references
andbabel
objects. (The macro you create should export a function).
The object references
has as many keys as exports
has the module macro.
That is the reason we write in this case references.default
, since there is only one.
The items in each array are the paths
to the references in the AST that
match.
references.default.forEach(referencePath => { ... });
This is how imagine it works:
The babel-plugin-macros
traverses the AST and each time it encounters a node containing a reference to idx
stores the corresponding
path
in a list of nodes. Later calls the function exported by the macro with the state
, babel
with that list of nodes in the references
object.
Notice that the line if (referencePath.parentPath.type === 'CallExpression')
refers to the parentPath
of the node that contains the reference since this points to the identifier and the parent is the "call" to the idx
function.
module.exports = createMacro(({ state, references }) => {
references.default.forEach(referencePath => {
if (referencePath.parentPath.type === 'CallExpression') { // 1
idx_transform(referencePath.parentPath, state);
} else { ...}
});
});
In our example there is only one call to the idx
function:
idx(props, _ => _.user.friends[0].friends)
but if there were more calls to the idx
function in the code, they would be stored in the references.default
array.
Where idx_transform
is a function that transforms the code:
const idx_transform = (path, state) => {
const node = path.node;
checkIdxArguments(state.file, node);
const temp = path.scope.generateUidIdentifier('ref');
const replacement = makeChain(node.arguments[1].body, {
file: state.file,
input: node.arguments[0],
base: node.arguments[1].params[0],
temp,
});
path.replaceWith(replacement);
path.scope.push({ id: temp });
};
The function checkIdxArguments
checks the arguments of the idx
function
and will throw an error if any of the arguments is not correct
The call temp = path.scope.generateUidIdentifier('ref')
generates
an Identifier
node that doesn't collide with any of the variables defined
in the scope of the node being visited path.scope
. It returns something like
Node { type: "Identifier", name: "_ref" }
in the example above.
This generated identifier will be later inserted into the scope of the node being visited with
path.scope.push({ id: temp })
:
➜ tan-li git:(main) ✗ npx babel use-macro.mjs
var _ref;
const friends_of_friends = (_ref = props) != null ? (_ref = _ref.user) != null ? ...
The function checkIdxArguments
checks the arguments of the idx
function
and will throw an error if any of the arguments is not correct
function checkIdxArguments(file, node) {
const args = node.arguments;
if (args.length !== 2) {
throw file.buildCodeFrameError(
node,
'The `idx` function takes exactly two arguments.'
);
}
const arrowFunction = args[1];
if (!t.isArrowFunctionExpression(arrowFunction)) {
throw file.buildCodeFrameError(
arrowFunction,
'The second argument supplied to `idx` must be an arrow function.'
);
}
if (!t.isExpression(arrowFunction.body)) {
throw file.buildCodeFrameError(
arrowFunction.body,
'The body of the arrow function supplied to `idx` must be a single ' +
'expression (without curly braces).'
);
}
if (arrowFunction.params.length !== 1) {
throw file.buildCodeFrameError(
arrowFunction.params[2] || arrowFunction,
'The arrow function supplied to `idx` must take exactly one parameter.'
);
}
const input = arrowFunction.params[0];
if (!t.isIdentifier(input)) {
throw file.buildCodeFrameError(
arrowFunction.params[0],
'The parameter supplied to `idx` must be an identifier.'
);
}
}
The state.file
object has a buildCodeFrameError(node, mesage)
method of the file
object that is used to build an error message with the line and column number of the node being visited.
The node that will be used to replace the call is built by the function makeChain
:
const replacement = makeChain(node.arguments[1].body, { // The state parameter
file: state.file,
input: node.arguments[0],
base: node.arguments[1].params[0],
temp,
} //, The inside parameter is undefined
);
Notice that the temp
variable is passed to the makeChain
function.
The current node is a call to idx
with two arguments, the first one is the input object
and the second one is an arrow function that will be used to access the properties of the input object:
const friends_of_friends = idx(props, _ => _.user.friends[0].friends);
thus
node.arguments[1].body
is the AST of the arrow function body andnode.arguments[0]
is the AST of the input object.node.arguments[1].params[0]
is the parameter of the arrow function, in this case_
.- The
state.file
object is passed to be used in the error messages. For instancestate.file.opts.filename
will give the full path/Users/casianorodriguezleon/campus-virtual/2324/learning/babel-macros/src/tan-li/use-macro.mjs
of the file being transformed. andstate.file.sourceFileName
will give the name of the fileuse-macro.mjs
.
Let us first see the function makeCondition
, which is used to build the conditional expression for a node
, assuming
the inside
contains code that will be executed if the condition is true and state.temp
:
function makeCondition(node, state, inside) {
if (inside) {
return t.ConditionalExpression( // S.t. like (_ref = _ref.user) != null ? _ref.friends /* inside */ : _ref /* state.temp */
t.BinaryExpression( // S.t. like (_ref = _ref.user) != null
'!=',
t.AssignmentExpression('=', state.temp, node), // S.t. like _ref = _ref.user
t.NullLiteral()
),
inside, // _ref.friends
state.temp // _ref
);
} else {
return node;
}
}
The function makeChain
is a function that builds the chain of properties and methods that will be used to access the input object.
Basically it build the condition for the current node
and recursively calls itself for the next node in the chain.
function makeChain(node, state, inside) {
if (t.isCallExpression(node)) { // props().user
return makeChain(
node.callee,
state,
makeCondition(t.CallExpression(state.temp, node.arguments), state, inside)
);
} else if (t.isMemberExpression(node)) { // user.friends or friends[0]
return makeChain(
node.object,
state,
makeCondition(
t.MemberExpression(state.temp, node.property, node.computed),
state,
inside
)
);
} else if (t.isIdentifier(node)) { // The base case: user
if (node.name !== state.base.name) {
throw state.file.buildCodeFrameError(
node,
'The parameter of the arrow function supplied to `idx` must match ' +
'the base of the body expression.'
);
}
return makeCondition(state.input, state, inside);
} else { // The recursive call is not a CallExpression, MemberExpression or Identifier
throw state.file.buildCodeFrameError(
node,
'The `idx` body can only be composed of properties and methods.'
);
}
}
The if (t.isCallExpression(node)) { ... }
block is executed when the body is a CallExpression
like
in idx(props, f => f().user)
. In such case the function makeChain
is called recursively with the callee
,
the same state
and the inside
argument is now the recursive call
makeCondition(t.CallExpression(state.temp, node.arguments), state, inside)
.
return makeChain(node.callee, state,
makeCondition(t.CallExpression(state.temp, node.arguments), state, inside));
The if (t.isMemberExpression(node)) { ... }
block is executed when the body is a MemberExpression
like
in idx(props, f => f.user
. In such case the function makeChain
is called recursively with the object
,
the same state
and the inside
argument is now the recursive call
return makeChain(node.object, state,
makeCondition(t.MemberExpression(state.temp, node.property, node.computed), state, inside));
The if (t.isIdentifier(node)) { ... }
block is executed when the body is an Identifier
like
in idx(props, f => f
. In such case the function makeChain
is called recursively with the object
,
the same state
and the inside
argument is now the recursive call
if (node.name !== state.base.name) {
throw state.file.buildCodeFrameError(
node,
'The parameter of the arrow function supplied to `idx` must match ' +
'the base of the body expression.'
);
}
return makeCondition(state.input, state, inside);
- dralletje/idx.macro A 'babel-macros' version of 'babel-plugin-idx'
- I Can Babel Macros (and So Can You!) by Shawn "swyx" Wang. JSConf Hawaii 2019
- Babel REPL by Brian Vaughn
- Compilers are the New Frameworks by Tom Dale
- The new wave of frameworks: what can we learn from compiler-based frameworks? by Tan Liu Hau 2023. Xitu Developers Conferences