Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow await expressions in optional chaining and nullish coalescing #497

Merged
merged 1 commit into from
Jan 1, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/HelperManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ const HELPERS = {
}
}
`,
asyncNullishCoalesce: `
async function asyncNullishCoalesce(lhs, rhsFn) {
if (lhs != null) {
return lhs;
} else {
return await rhsFn();
}
}
`,
optionalChain: `
function optionalChain(ops) {
let lastAccessLHS = undefined;
Expand All @@ -77,12 +86,41 @@ const HELPERS = {
return value;
}
`,
asyncOptionalChain: `
async function asyncOptionalChain(ops) {
let lastAccessLHS = undefined;
let value = ops[0];
let i = 1;
while (i < ops.length) {
const op = ops[i];
const fn = ops[i + 1];
i += 2;
if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) {
return undefined;
}
if (op === 'access' || op === 'optionalAccess') {
lastAccessLHS = value;
value = await fn(value);
} else if (op === 'call' || op === 'optionalCall') {
value = await fn((...args) => value.call(lastAccessLHS, ...args));
lastAccessLHS = undefined;
}
}
return value;
}
`,
optionalChainDelete: `
function optionalChainDelete(ops) {
const result = OPTIONAL_CHAIN_NAME(ops);
return result == null ? true : result;
}
`,
asyncOptionalChainDelete: `
async function asyncOptionalChainDelete(ops) {
const result = await ASYNC_OPTIONAL_CHAIN_NAME(ops);
return result == null ? true : result;
}
`,
};

export class HelperManager {
Expand All @@ -104,11 +142,19 @@ export class HelperManager {
if (this.helperNames.optionalChainDelete) {
this.getHelperName("optionalChain");
}
if (this.helperNames.asyncOptionalChainDelete) {
this.getHelperName("asyncOptionalChain");
}
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!);
} else if (baseName === "asyncOptionalChainDelete") {
helperCode = helperCode.replace(
"ASYNC_OPTIONAL_CHAIN_NAME",
this.helperNames.asyncOptionalChain!,
);
}
if (helperName) {
resultCode += " ";
Expand Down
22 changes: 20 additions & 2 deletions src/TokenProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {HelperManager} from "./HelperManager";
import {Token} from "./parser/tokenizer";
import {ContextualKeyword} from "./parser/tokenizer/keywords";
import {TokenType, TokenType as tt} from "./parser/tokenizer/types";
import isAsyncOperation from "./util/isAsyncOperation";

export interface TokenProcessorSnapshot {
resultCode: string;
Expand Down Expand Up @@ -206,15 +207,32 @@ export default class TokenProcessor {

private appendTokenPrefix(): void {
const token = this.currentToken();
if (token.numNullishCoalesceStarts || token.isOptionalChainStart) {
token.isAsyncOperation = isAsyncOperation(this);
}
if (token.numNullishCoalesceStarts) {
for (let i = 0; i < token.numNullishCoalesceStarts; i++) {
this.resultCode += this.helperManager.getHelperName("nullishCoalesce");
if (token.isAsyncOperation) {
this.resultCode += "await ";
this.resultCode += this.helperManager.getHelperName("asyncNullishCoalesce");
} else {
this.resultCode += this.helperManager.getHelperName("nullishCoalesce");
}
this.resultCode += "(";
}
}
if (token.isOptionalChainStart) {
if (token.isAsyncOperation) {
this.resultCode += "await ";
}
if (this.tokenIndex > 0 && this.tokenAtRelativeIndex(-1).type === tt._delete) {
this.resultCode += this.helperManager.getHelperName("optionalChainDelete");
if (token.isAsyncOperation) {
this.resultCode += this.helperManager.getHelperName("asyncOptionalChainDelete");
} else {
this.resultCode += this.helperManager.getHelperName("optionalChainDelete");
}
} else if (token.isAsyncOperation) {
this.resultCode += this.helperManager.getHelperName("asyncOptionalChain");
} else {
this.resultCode += this.helperManager.getHelperName("optionalChain");
}
Expand Down
8 changes: 8 additions & 0 deletions src/parser/tokenizer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class Token {
this.contextualKeyword = state.contextualKeyword;
this.start = state.start;
this.end = state.end;
this.scopeDepth = state.scopeDepth;
this.isType = state.isType;
this.identifierRole = null;
this.shadowsGlobal = false;
Expand All @@ -105,17 +106,22 @@ export class Token {
this.isOptionalChainStart = false;
this.isOptionalChainEnd = false;
this.subscriptStartIndex = null;
this.nullishStartIndex = null;
}

type: TokenType;
contextualKeyword: ContextualKeyword;
start: number;
end: number;
scopeDepth: number;
isType: boolean;
identifierRole: IdentifierRole | null;
// Initially false for all tokens, then may be computed in a follow-up step that does scope
// analysis.
shadowsGlobal: boolean;
// Initially false for all tokens, but may be set during transform to mark it as containing an
// await operation.
isAsyncOperation: boolean;
contextId: number | null;
// For assignments, the index of the RHS. For export tokens, the end of the export.
rhsEndIndex: number | null;
Expand All @@ -132,6 +138,8 @@ export class Token {
// Tag for `.`, `?.`, `[`, `?.[`, `(`, and `?.(` to denote the "root" token for this
// subscript chain. This can be used to determine if this chain is an optional chain.
subscriptStartIndex: number | null;
// Tag for `??` operators to denote the root token for this nullish coalescing call.
nullishStartIndex: number | null;
}

// ## Tokenizer
Expand Down
3 changes: 3 additions & 0 deletions src/parser/traverser/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ function parseExprOp(startTokenIndex: number, minPrec: number, noIn: boolean): v
if (prec > minPrec) {
const op = state.type;
next();
if (op === tt.nullishCoalescing) {
state.tokens[state.tokens.length - 1].nullishStartIndex = startTokenIndex;
}

const rhsStartTokenIndex = state.tokens.length;
parseMaybeUnary();
Expand Down
10 changes: 9 additions & 1 deletion src/transformers/OptionalChainingNullishTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export default class OptionalChainingNullishTransformer extends Transformer {

process(): boolean {
if (this.tokens.matches1(tt.nullishCoalescing)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(", () =>");
const token = this.tokens.currentToken();
if (this.tokens.tokens[token.nullishStartIndex!].isAsyncOperation) {
this.tokens.replaceTokenTrimmingLeftWhitespace(", async () =>");
} else {
this.tokens.replaceTokenTrimmingLeftWhitespace(", () =>");
}
return true;
}
if (this.tokens.matches1(tt._delete)) {
Expand All @@ -46,6 +51,9 @@ export default class OptionalChainingNullishTransformer extends Transformer {
} else {
arrowStartSnippet = `${param} => ${param}`;
}
if (this.tokens.tokens[chainStart].isAsyncOperation) {
arrowStartSnippet = `async ${arrowStartSnippet}`;
}
if (
this.tokens.matches2(tt.questionDot, tt.parenL) ||
this.tokens.matches2(tt.questionDot, tt.lessThan)
Expand Down
38 changes: 38 additions & 0 deletions src/util/isAsyncOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {ContextualKeyword} from "../parser/tokenizer/keywords";
import TokenProcessor from "../TokenProcessor";

/**
* Determine whether this optional chain or nullish coalescing operation has any await statements in
* it. If so, we'll need to transpile to an async operation.
*
* We compute this by walking the length of the operation and returning true if we see an await
* keyword used as a real await (rather than an object key or property access). Nested optional
* chain/nullish operations need to be tracked but don't silence await, but a nested async function
* (or any other nested scope) will make the await not count.
*/
export default function isAsyncOperation(tokens: TokenProcessor): boolean {
let index = tokens.currentIndex();
let depth = 0;
const startToken = tokens.currentToken();
do {
const token = tokens.tokens[index];
if (token.isOptionalChainStart) {
depth++;
}
if (token.isOptionalChainEnd) {
depth--;
}
depth += token.numNullishCoalesceStarts;
depth -= token.numNullishCoalesceEnds;

if (
token.contextualKeyword === ContextualKeyword._await &&
token.identifierRole == null &&
token.scopeDepth === startToken.scopeDepth
) {
return true;
}
index += 1;
} while (depth > 0 && index < tokens.tokens.length);
return false;
}
30 changes: 15 additions & 15 deletions test/index-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@ if (foo) {
{transforms: ["jsx", "imports"]},
),
`\
Location Label Raw contextualKeyword isType identifierRole shadowsGlobal contextId rhsEndIndex isExpression numNullishCoalesceStarts numNullishCoalesceEnds isOptionalChainStart isOptionalChainEnd subscriptStartIndex
1:1-1:3 if if 0 0 0
1:4-1:5 ( ( 0 0 0
1:5-1:8 name foo 0 0 0 0
1:8-1:9 ) ) 0 0 0
1:10-1:11 { { 0 0 0
2:3-2:10 name console 0 0 0 0
2:10-2:11 . . 0 0 0 5
2:11-2:14 name log 0 0 0
2:14-2:15 ( ( 0 1 0 0 5
2:15-2:29 string 'Hello world!' 0 0 0
2:29-2:30 ) ) 0 1 0 0
2:30-2:31 ; ; 0 0 0
3:1-3:2 } } 0 0 0
3:2-3:2 eof 0 0 0 `,
Location Label Raw contextualKeyword scopeDepth isType identifierRole shadowsGlobal contextId rhsEndIndex isExpression numNullishCoalesceStarts numNullishCoalesceEnds isOptionalChainStart isOptionalChainEnd subscriptStartIndex nullishStartIndex
1:1-1:3 if if 0 0 0 0
1:4-1:5 ( ( 0 0 0 0
1:5-1:8 name foo 0 0 0 0 0
1:8-1:9 ) ) 0 0 0 0
1:10-1:11 { { 0 1 0 0
2:3-2:10 name console 0 1 0 0 0
2:10-2:11 . . 0 1 0 0 5
2:11-2:14 name log 0 1 0 0
2:14-2:15 ( ( 0 1 1 0 0 5
2:15-2:29 string 'Hello world!' 0 1 0 0
2:29-2:30 ) ) 0 1 1 0 0
2:30-2:31 ; ; 0 1 0 0
3:1-3:2 } } 0 1 0 0
3:2-3:2 eof 0 0 0 0 `,
);
});
});
13 changes: 13 additions & 0 deletions test/prefixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ var enterModule = require('react-hot-loader').enterModule; enterModule && enterM
})();`;
export const NULLISH_COALESCE_PREFIX = ` function _nullishCoalesce(lhs, rhsFn) { \
if (lhs != null) { return lhs; } else { return rhsFn(); } }`;
export const ASYNC_NULLISH_COALESCE_PREFIX = ` async function _asyncNullishCoalesce(lhs, rhsFn) { \
if (lhs != null) { return lhs; } else { return await rhsFn(); } }`;
export const OPTIONAL_CHAIN_PREFIX = ` function _optionalChain(ops) { \
let lastAccessLHS = undefined; let value = ops[0]; let i = 1; \
while (i < ops.length) { \
Expand All @@ -28,5 +30,16 @@ 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 ASYNC_OPTIONAL_CHAIN_PREFIX = ` async function _asyncOptionalChain(ops) { \
let lastAccessLHS = undefined; let value = ops[0]; let i = 1; \
while (i < ops.length) { \
const op = ops[i]; const fn = ops[i + 1]; i += 2; \
if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } \
if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = await fn(value); } \
else if (op === 'call' || op === 'optionalCall') { \
value = await 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; }`;
export const ASYNC_OPTIONAL_CHAIN_DELETE_PREFIX = ` async function _asyncOptionalChainDelete(ops) { \
const result = await _asyncOptionalChain(ops); return result == null ? true : result; }`;
67 changes: 67 additions & 0 deletions test/sucrase-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {
ASYNC_NULLISH_COALESCE_PREFIX,
ASYNC_OPTIONAL_CHAIN_DELETE_PREFIX,
ASYNC_OPTIONAL_CHAIN_PREFIX,
ESMODULE_PREFIX,
IMPORT_DEFAULT_PREFIX,
NULLISH_COALESCE_PREFIX,
Expand Down Expand Up @@ -1025,4 +1028,68 @@ describe("sucrase", () => {
{transforms: []},
);
});

it("correctly transforms async functions using await in an optional chain", () => {
assertResult(
`
async function foo() {
a?.b(await f())
}
`,
`${ASYNC_OPTIONAL_CHAIN_PREFIX}
async function foo() {
await _asyncOptionalChain([a, 'optionalAccess', async _ => _.b, 'call', async _2 => _2(await f())])
}
`,
{transforms: []},
);
});

it("does not mistake an unrelated await keyword for an async optional chain", () => {
assertResult(
`
function foo() {
a?.b(async () => await f())
}
`,
`${OPTIONAL_CHAIN_PREFIX}
function foo() {
_optionalChain([a, 'optionalAccess', _ => _.b, 'call', _2 => _2(async () => await f())])
}
`,
{transforms: []},
);
});

it("correctly transforms async functions using await in an optional chain deletion", () => {
assertResult(
`
async function foo() {
delete a?.[await f()];
}
`,
`${ASYNC_OPTIONAL_CHAIN_PREFIX}${ASYNC_OPTIONAL_CHAIN_DELETE_PREFIX}
async function foo() {
await _asyncOptionalChainDelete([a, 'optionalAccess', async _ => _[await f()]]);
}
`,
{transforms: []},
);
});

it("correctly transforms async functions using await in nullish coalescing", () => {
assertResult(
`
async function foo() {
return a ?? await b();
}
`,
`${ASYNC_NULLISH_COALESCE_PREFIX}
async function foo() {
return await _asyncNullishCoalesce(a, async () => await b());
}
`,
{transforms: []},
);
});
});