diff --git a/src/HelperManager.ts b/src/HelperManager.ts index bd6235eb..cbc8be96 100644 --- a/src/HelperManager.ts +++ b/src/HelperManager.ts @@ -77,6 +77,12 @@ const HELPERS = { return value; } `, + optionalChainDelete: ` + function optionalChainDelete(ops) { + const result = OPTIONAL_CHAIN_NAME(ops); + return result == null ? true : result; + } + `, }; export class HelperManager { @@ -95,8 +101,15 @@ export class HelperManager { emitHelpers(): string { let resultCode = ""; - for (const [baseName, helperCode] of Object.entries(HELPERS)) { + if (this.helperNames.optionalChainDelete) { + this.getHelperName("optionalChain"); + } + for (const [baseName, helperCodeTemplate] of Object.entries(HELPERS)) { const helperName = this.helperNames[baseName]; + let helperCode = helperCodeTemplate; + if (baseName === "optionalChainDelete") { + helperCode = helperCode.replace("OPTIONAL_CHAIN_NAME", this.helperNames.optionalChain!); + } if (helperName) { resultCode += " "; resultCode += helperCode diff --git a/src/TokenProcessor.ts b/src/TokenProcessor.ts index 552c8a6d..4fb4dc9e 100644 --- a/src/TokenProcessor.ts +++ b/src/TokenProcessor.ts @@ -213,7 +213,11 @@ export default class TokenProcessor { } } if (token.isOptionalChainStart) { - this.resultCode += this.helperManager.getHelperName("optionalChain"); + if (this.tokenIndex > 0 && this.tokenAtRelativeIndex(-1).type === tt._delete) { + this.resultCode += this.helperManager.getHelperName("optionalChainDelete"); + } else { + this.resultCode += this.helperManager.getHelperName("optionalChain"); + } this.resultCode += "(["; } } diff --git a/src/transformers/OptionalChainingNullishTransformer.ts b/src/transformers/OptionalChainingNullishTransformer.ts index 85e9b607..c4bd173c 100644 --- a/src/transformers/OptionalChainingNullishTransformer.ts +++ b/src/transformers/OptionalChainingNullishTransformer.ts @@ -22,24 +22,42 @@ export default class OptionalChainingNullishTransformer extends Transformer { this.tokens.replaceTokenTrimmingLeftWhitespace(", () =>"); return true; } + if (this.tokens.matches1(tt._delete)) { + const nextToken = this.tokens.tokenAtRelativeIndex(1); + if (nextToken.isOptionalChainStart) { + this.tokens.removeInitialToken(); + return true; + } + } const token = this.tokens.currentToken(); - if ( - token.subscriptStartIndex != null && - this.tokens.tokens[token.subscriptStartIndex].isOptionalChainStart - ) { + const chainStart = token.subscriptStartIndex; + if (chainStart != null && this.tokens.tokens[chainStart].isOptionalChainStart) { const param = this.nameManager.claimFreeName("_"); + let arrowStartSnippet; + if ( + chainStart > 0 && + this.tokens.matches1AtIndex(chainStart - 1, tt._delete) && + this.isLastSubscriptInChain() + ) { + // Delete operations are special: we already removed the delete keyword, and to still + // perform a delete, we need to insert a delete in the very last part of the chain, which + // in correct code will always be a property access. + arrowStartSnippet = `${param} => delete ${param}`; + } else { + arrowStartSnippet = `${param} => ${param}`; + } if (this.tokens.matches2(tt.questionDot, tt.parenL)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalCall', ${param} => ${param}`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalCall', ${arrowStartSnippet}`); } else if (this.tokens.matches2(tt.questionDot, tt.bracketL)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${param} => ${param}`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${arrowStartSnippet}`); } else if (this.tokens.matches1(tt.questionDot)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${param} => ${param}.`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${arrowStartSnippet}.`); } else if (this.tokens.matches1(tt.dot)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${param} => ${param}.`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${arrowStartSnippet}.`); } else if (this.tokens.matches1(tt.bracketL)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${param} => ${param}[`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${arrowStartSnippet}[`); } else if (this.tokens.matches1(tt.parenL)) { - this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'call', ${param} => ${param}(`); + this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'call', ${arrowStartSnippet}(`); } else { throw new Error("Unexpected subscript operator in optional chain."); } @@ -47,4 +65,35 @@ export default class OptionalChainingNullishTransformer extends Transformer { } return false; } + + /** + * Determine if the current token is the last of its chain, so that we know whether it's eligible + * to have a delete op inserted. + * + * We can do this by walking forward until we determine one way or another. Each + * isOptionalChainStart token must be paired with exactly one isOptionalChainEnd token after it in + * a nesting way, so we can track depth and walk to the end of the chain (the point where the + * depth goes negative) and see if any other subscript token is after us in the chain. + */ + isLastSubscriptInChain(): boolean { + let depth = 0; + for (let i = this.tokens.currentIndex() + 1; ; i++) { + if (i >= this.tokens.tokens.length) { + throw new Error("Reached the end of the code while finding the end of the access chain."); + } + if (this.tokens.tokens[i].isOptionalChainStart) { + depth++; + } else if (this.tokens.tokens[i].isOptionalChainEnd) { + depth--; + } + if (depth < 0) { + return true; + } + + // This subscript token is a later one in the same chain. + if (depth === 0 && this.tokens.tokens[i].subscriptStartIndex != null) { + return false; + } + } + } } diff --git a/test/prefixes.ts b/test/prefixes.ts index 8a557bac..3ec9a3f8 100644 --- a/test/prefixes.ts +++ b/test/prefixes.ts @@ -28,3 +28,5 @@ if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = else if (op === 'call' || op === 'optionalCall') { \ value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; \ } } return value; }`; +export const OPTIONAL_CHAIN_DELETE_PREFIX = ` function _optionalChainDelete(ops) { \ +const result = _optionalChain(ops); return result == null ? true : result; }`; diff --git a/test/sucrase-test.ts b/test/sucrase-test.ts index 4c49c8a9..a2a5807c 100644 --- a/test/sucrase-test.ts +++ b/test/sucrase-test.ts @@ -2,6 +2,7 @@ import { ESMODULE_PREFIX, IMPORT_DEFAULT_PREFIX, NULLISH_COALESCE_PREFIX, + OPTIONAL_CHAIN_DELETE_PREFIX, OPTIONAL_CHAIN_PREFIX, } from "./prefixes"; import {assertOutput, assertResult} from "./util"; @@ -913,7 +914,7 @@ describe("sucrase", () => { ); }); - it("handles nested optional chain operations", () => { + it("handles nested nullish coalescing operations", () => { assertOutput( ` setOutput(undefined ?? 7 ?? null); @@ -976,4 +977,52 @@ describe("sucrase", () => { {transforms: []}, ); }); + + it("transpiles optional chain deletion", () => { + assertResult( + ` + delete a?.b.c; + `, + `${OPTIONAL_CHAIN_PREFIX}${OPTIONAL_CHAIN_DELETE_PREFIX} + _optionalChainDelete([a, 'optionalAccess', _ => _.b, 'access', _2 => delete _2.c]); + `, + {transforms: []}, + ); + }); + + it("correctly identifies last element of optional chain deletion", () => { + assertResult( + ` + delete a?.b[c?.c]; + `, + `${OPTIONAL_CHAIN_PREFIX}${OPTIONAL_CHAIN_DELETE_PREFIX} + _optionalChainDelete([a, 'optionalAccess', _ => _.b, 'access', _2 => delete _2[_optionalChain([c, 'optionalAccess', _3 => _3.c])]]); + `, + {transforms: []}, + ); + }); + + it("deletes the property correctly with optional chain deletion", () => { + assertOutput( + ` + const o = {x: 1}; + delete o?.x; + setOutput(o.hasOwnProperty('x')) + `, + false, + {transforms: []}, + ); + }); + + it("does not crash with optional chain deletion on null", () => { + assertOutput( + ` + const o = null; + delete o?.x; + setOutput(o) + `, + null, + {transforms: []}, + ); + }); });