import * as t from "@babel/types"; class AssignmentMemoiser { constructor() { this._map = new WeakMap(); } has(key) { return this._map.has(key); } get(key) { if (!this.has(key)) return; const record = this._map.get(key); const { value } = record; record.count--; if (record.count === 0) { // The `count` access is the outermost function call (hopefully), so it // does the assignment. return t.assignmentExpression("=", value, key); } return value; } set(key, value, count) { return this._map.set(key, { count, value }); } } function toNonOptional(path, base) { const { node } = path; if (path.isOptionalMemberExpression()) { return t.memberExpression(base, node.property, node.computed); } if (path.isOptionalCallExpression()) { const callee = path.get("callee"); if (path.node.optional && callee.isOptionalMemberExpression()) { const { object } = callee.node; const context = path.scope.maybeGenerateMemoised(object) || object; callee .get("object") .replaceWith(t.assignmentExpression("=", context, object)); return t.callExpression(t.memberExpression(base, t.identifier("call")), [ context, ...node.arguments, ]); } return t.callExpression(base, node.arguments); } return path.node; } // Determines if the current path is in a detached tree. This can happen when // we are iterating on a path, and replace an ancestor with a new node. Babel // doesn't always stop traversing the old node tree, and that can cause // inconsistencies. function isInDetachedTree(path) { while (path) { if (path.isProgram()) break; const { parentPath, container, listKey } = path; const parentNode = parentPath.node; if (listKey) { if (container !== parentNode[listKey]) return true; } else { if (container !== parentNode) return true; } path = parentPath; } return false; } const handle = { memoise() { // noop. }, handle(member) { const { node, parent, parentPath } = member; if (member.isOptionalMemberExpression()) { // Transforming optional chaining requires we replace ancestors. if (isInDetachedTree(member)) return; // We're looking for the end of _this_ optional chain, which is actually // the "rightmost" property access of the chain. This is because // everything up to that property access is "optional". // // Let's take the case of `FOO?.BAR.baz?.qux`, with `FOO?.BAR` being our // member. The "end" to most users would be `qux` property access. // Everything up to it could be skipped if it `FOO` were nullish. But // actually, we can consider the `baz` access to be the end. So we're // looking for the nearest optional chain that is `optional: true`. const endPath = member.find(({ node, parent, parentPath }) => { if (parentPath.isOptionalMemberExpression()) { // We need to check `parent.object` since we could be inside the // computed expression of a `bad?.[FOO?.BAR]`. In this case, the // endPath is the `FOO?.BAR` member itself. return parent.optional || parent.object !== node; } if (parentPath.isOptionalCallExpression()) { // Checking `parent.callee` since we could be in the arguments, eg // `bad?.(FOO?.BAR)`. // Also skip `FOO?.BAR` in `FOO?.BAR?.()` since we need to transform the optional call to ensure proper this return ( // In FOO?.#BAR?.(), endPath points the optional call expression so we skip FOO?.#BAR (node !== member.node && parent.optional) || parent.callee !== node ); } return true; }); const rootParentPath = endPath.parentPath; if ( rootParentPath.isUpdateExpression({ argument: node }) || rootParentPath.isAssignmentExpression({ left: node }) ) { throw member.buildCodeFrameError(`can't handle assignment`); } if (rootParentPath.isUnaryExpression({ operator: "delete" })) { throw member.buildCodeFrameError(`can't handle delete`); } // Now, we're looking for the start of this optional chain, which is // optional to the left of this member. // // Let's take the case of `foo?.bar?.baz.QUX?.BAM`, with `QUX?.BAM` being // our member. The "start" to most users would be `foo` object access. // But actually, we can consider the `bar` access to be the start. So // we're looking for the nearest optional chain that is `optional: true`, // which is guaranteed to be somewhere in the object/callee tree. let startingOptional = member; for (;;) { if (startingOptional.isOptionalMemberExpression()) { if (startingOptional.node.optional) break; startingOptional = startingOptional.get("object"); continue; } else if (startingOptional.isOptionalCallExpression()) { if (startingOptional.node.optional) break; startingOptional = startingOptional.get("callee"); continue; } // prevent infinite loop: unreachable if the AST is well-formed throw new Error( `Internal error: unexpected ${startingOptional.node.type}`, ); } const { scope } = member; const startingProp = startingOptional.isOptionalMemberExpression() ? "object" : "callee"; const startingNode = startingOptional.node[startingProp]; const baseNeedsMemoised = scope.maybeGenerateMemoised(startingNode); const baseRef = baseNeedsMemoised ?? startingNode; // Compute parentIsOptionalCall before `startingOptional` is replaced // as `node` may refer to `startingOptional.node` before replaced. const parentIsOptionalCall = parentPath.isOptionalCallExpression({ callee: node, }); // if parentIsCall is true, it implies that node.extra.parenthesized is always true const parentIsCall = parentPath.isCallExpression({ callee: node }); startingOptional.replaceWith(toNonOptional(startingOptional, baseRef)); if (parentIsOptionalCall) { if (parent.optional) { parentPath.replaceWith(this.optionalCall(member, parent.arguments)); } else { parentPath.replaceWith(this.call(member, parent.arguments)); } } else if (parentIsCall) { // `(a?.#b)()` to `(a == null ? void 0 : a.#b.bind(a))()` member.replaceWith(this.boundGet(member)); } else { member.replaceWith(this.get(member)); } let regular = member.node; for (let current = member; current !== endPath; ) { const { parentPath } = current; // skip transforming `Foo.#BAR?.call(FOO)` if (parentPath === endPath && parentIsOptionalCall && parent.optional) { regular = parentPath.node; break; } regular = toNonOptional(parentPath, regular); current = parentPath; } let context; const endParentPath = endPath.parentPath; if ( t.isMemberExpression(regular) && endParentPath.isOptionalCallExpression({ callee: endPath.node, optional: true, }) ) { const { object } = regular; context = member.scope.maybeGenerateMemoised(object); if (context) { regular.object = t.assignmentExpression("=", context, object); } } endPath.replaceWith( t.conditionalExpression( t.logicalExpression( "||", t.binaryExpression( "===", baseNeedsMemoised ? t.assignmentExpression("=", baseRef, startingNode) : baseRef, t.nullLiteral(), ), t.binaryExpression( "===", t.cloneNode(baseRef), scope.buildUndefinedNode(), ), ), scope.buildUndefinedNode(), regular, ), ); if (context) { const endParent = endParentPath.node; endParentPath.replaceWith( t.optionalCallExpression( t.optionalMemberExpression( endParent.callee, t.identifier("call"), false, true, ), [context, ...endParent.arguments], false, ), ); } return; } // MEMBER++ -> _set(MEMBER, (_ref = (+_get(MEMBER))) + 1), _ref // ++MEMBER -> _set(MEMBER, (+_get(MEMBER)) + 1) if (parentPath.isUpdateExpression({ argument: node })) { if (this.simpleSet) { member.replaceWith(this.simpleSet(member)); return; } const { operator, prefix } = parent; // Give the state handler a chance to memoise the member, since we'll // reference it twice. The second access (the set) should do the memo // assignment. this.memoise(member, 2); const value = t.binaryExpression( operator[0], t.unaryExpression("+", this.get(member)), t.numericLiteral(1), ); if (prefix) { parentPath.replaceWith(this.set(member, value)); } else { const { scope } = member; const ref = scope.generateUidIdentifierBasedOnNode(node); scope.push({ id: ref }); value.left = t.assignmentExpression("=", t.cloneNode(ref), value.left); parentPath.replaceWith( t.sequenceExpression([this.set(member, value), t.cloneNode(ref)]), ); } return; } // MEMBER = VALUE -> _set(MEMBER, VALUE) // MEMBER += VALUE -> _set(MEMBER, _get(MEMBER) + VALUE) if (parentPath.isAssignmentExpression({ left: node })) { if (this.simpleSet) { member.replaceWith(this.simpleSet(member)); return; } const { operator, right } = parent; let value = right; if (operator !== "=") { // Give the state handler a chance to memoise the member, since we'll // reference it twice. The second access (the set) should do the memo // assignment. this.memoise(member, 2); value = t.binaryExpression( operator.slice(0, -1), this.get(member), value, ); } parentPath.replaceWith(this.set(member, value)); return; } // MEMBER(ARGS) -> _call(MEMBER, ARGS) if (parentPath.isCallExpression({ callee: node })) { parentPath.replaceWith(this.call(member, parent.arguments)); return; } // MEMBER?.(ARGS) -> _optionalCall(MEMBER, ARGS) if (parentPath.isOptionalCallExpression({ callee: node })) { parentPath.replaceWith(this.optionalCall(member, parent.arguments)); return; } // for (MEMBER of ARR) // for (MEMBER in ARR) // { KEY: MEMBER } = OBJ -> { KEY: _destructureSet(MEMBER) } = OBJ // { KEY: MEMBER = _VALUE } = OBJ -> { KEY: _destructureSet(MEMBER) = _VALUE } = OBJ // {...MEMBER} -> {..._destructureSet(MEMBER)} // // [MEMBER] = ARR -> [_destructureSet(MEMBER)] = ARR // [MEMBER = _VALUE] = ARR -> [_destructureSet(MEMBER) = _VALUE] = ARR // [...MEMBER] -> [..._destructureSet(MEMBER)] if ( // for (MEMBER of ARR) // for (MEMBER in ARR) parentPath.isForXStatement({ left: node }) || // { KEY: MEMBER } = OBJ (parentPath.isObjectProperty({ value: node }) && parentPath.parentPath.isObjectPattern()) || // { KEY: MEMBER = _VALUE } = OBJ (parentPath.isAssignmentPattern({ left: node }) && parentPath.parentPath.isObjectProperty({ value: parent }) && parentPath.parentPath.parentPath.isObjectPattern()) || // [MEMBER] = ARR parentPath.isArrayPattern() || // [MEMBER = _VALUE] = ARR (parentPath.isAssignmentPattern({ left: node }) && parentPath.parentPath.isArrayPattern()) || // {...MEMBER} // [...MEMBER] parentPath.isRestElement() ) { member.replaceWith(this.destructureSet(member)); return; } // MEMBER -> _get(MEMBER) member.replaceWith(this.get(member)); }, }; // We do not provide a default traversal visitor // Instead, caller passes one, and must call `state.handle` on the members // it wishes to be transformed. // Additionally, the caller must pass in a state object with at least // get, set, and call methods. // Optionally, a memoise method may be defined on the state, which will be // called when the member is a self-referential update. export default function memberExpressionToFunctions(path, visitor, state) { path.traverse(visitor, { ...handle, ...state, memoiser: new AssignmentMemoiser(), }); }