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

First pass at implementation of optional chaining #490

Merged
merged 1 commit into from
Dec 28, 2019
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
23 changes: 23 additions & 0 deletions src/HelperManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ const HELPERS = {
}
}
`,
optionalChain: `
function optionalChain(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 = fn(value);
} else if (op === 'call' || op === 'optionalCall') {
value = fn((...args) => value.call(lastAccessLHS, ...args));
lastAccessLHS = undefined;
}
}
return value;
}
`,
};

export class HelperManager {
Expand Down
7 changes: 7 additions & 0 deletions src/TokenProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,17 @@ export default class TokenProcessor {
this.resultCode += "(";
}
}
if (token.isOptionalChainStart) {
this.resultCode += this.helperManager.getHelperName("optionalChain");
this.resultCode += "([";
}
}

private appendTokenSuffix(): void {
const token = this.currentToken();
if (token.isOptionalChainEnd) {
this.resultCode += "])";
}
if (token.numNullishCoalesceEnds) {
for (let i = 0; i < token.numNullishCoalesceEnds; i++) {
this.resultCode += ")";
Expand Down
12 changes: 8 additions & 4 deletions src/parser/plugins/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,11 @@ export function flowParseFunctionBodyAndFinish(funcContextId: number): void {
parseFunctionBody(false, funcContextId);
}

export function flowParseSubscript(noCalls: boolean, stopState: StopState): void {
export function flowParseSubscript(
startTokenIndex: number,
noCalls: boolean,
stopState: StopState,
): void {
if (match(tt.questionDot) && lookaheadType() === tt.lessThan) {
if (noCalls) {
stopState.stop = true;
Expand All @@ -736,7 +740,7 @@ export function flowParseSubscript(noCalls: boolean, stopState: StopState): void
return;
}
}
baseParseSubscript(noCalls, stopState);
baseParseSubscript(startTokenIndex, noCalls, stopState);
}

export function flowStartParseNewArguments(): void {
Expand Down Expand Up @@ -1014,7 +1018,7 @@ export function flowParseArrow(): boolean {
return eat(tt.arrow);
}

export function flowParseSubscripts(noCalls: boolean = false): void {
export function flowParseSubscripts(startTokenIndex: number, noCalls: boolean = false): void {
if (
state.tokens[state.tokens.length - 1].contextualKeyword === ContextualKeyword._async &&
match(tt.lessThan)
Expand All @@ -1027,7 +1031,7 @@ export function flowParseSubscripts(noCalls: boolean = false): void {
state.restoreFromSnapshot(snapshot);
}

baseParseSubscripts(noCalls);
baseParseSubscripts(startTokenIndex, noCalls);
}

// Returns true if there was an arrow function here.
Expand Down
8 changes: 6 additions & 2 deletions src/parser/plugins/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,11 @@ export function tsParseFunctionBodyAndFinish(functionStart: number, funcContextI
parseFunctionBody(false, funcContextId);
}

export function tsParseSubscript(noCalls: boolean, stopState: StopState): void {
export function tsParseSubscript(
startTokenIndex: number,
noCalls: boolean,
stopState: StopState,
): void {
if (!hasPrecedingLineBreak() && eat(tt.bang)) {
state.tokens[state.tokens.length - 1].type = tt.nonNullAssertion;
return;
Expand Down Expand Up @@ -1089,7 +1093,7 @@ export function tsParseSubscript(noCalls: boolean, stopState: StopState): void {
return;
}
}
baseParseSubscript(noCalls, stopState);
baseParseSubscript(startTokenIndex, noCalls, stopState);
}

export function tsStartParseNewArguments(): void {
Expand Down
10 changes: 10 additions & 0 deletions src/parser/tokenizer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export class Token {
this.isExpression = false;
this.numNullishCoalesceStarts = 0;
this.numNullishCoalesceEnds = 0;
this.isOptionalChainStart = false;
this.isOptionalChainEnd = false;
this.subscriptStartIndex = null;
}

type: TokenType;
Expand All @@ -122,6 +125,13 @@ export class Token {
numNullishCoalesceStarts: number;
// Number of times to insert a `)` snippet after this token.
numNullishCoalesceEnds: number;
// If true, insert an `optionalChain([` snippet before this token.
isOptionalChainStart: boolean;
// If true, insert a `])` snippet after this token.
isOptionalChainEnd: boolean;
// 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;
}

// ## Tokenizer
Expand Down
49 changes: 34 additions & 15 deletions src/parser/traverser/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,51 +256,65 @@ export function parseMaybeUnary(): boolean {
// Parse call, dot, and `[]`-subscript expressions.
// Returns true if this was an arrow function.
export function parseExprSubscripts(): boolean {
const startTokenIndex = state.tokens.length;
const wasArrow = parseExprAtom();
if (wasArrow) {
return true;
}
parseSubscripts();
parseSubscripts(startTokenIndex);
// If there was any optional chain operation, the start token would be marked
// as such, so also mark the end now.
if (state.tokens.length > startTokenIndex && state.tokens[startTokenIndex].isOptionalChainStart) {
state.tokens[state.tokens.length - 1].isOptionalChainEnd = true;
}
return false;
}

function parseSubscripts(noCalls: boolean = false): void {
function parseSubscripts(startTokenIndex: number, noCalls: boolean = false): void {
if (isFlowEnabled) {
flowParseSubscripts(noCalls);
flowParseSubscripts(startTokenIndex, noCalls);
} else {
baseParseSubscripts(noCalls);
baseParseSubscripts(startTokenIndex, noCalls);
}
}

export function baseParseSubscripts(noCalls: boolean = false): void {
export function baseParseSubscripts(startTokenIndex: number, noCalls: boolean = false): void {
const stopState = new StopState(false);
do {
parseSubscript(noCalls, stopState);
parseSubscript(startTokenIndex, noCalls, stopState);
} while (!stopState.stop && !state.error);
}

function parseSubscript(noCalls: boolean, stopState: StopState): void {
function parseSubscript(startTokenIndex: number, noCalls: boolean, stopState: StopState): void {
if (isTypeScriptEnabled) {
tsParseSubscript(noCalls, stopState);
tsParseSubscript(startTokenIndex, noCalls, stopState);
} else if (isFlowEnabled) {
flowParseSubscript(noCalls, stopState);
flowParseSubscript(startTokenIndex, noCalls, stopState);
} else {
baseParseSubscript(noCalls, stopState);
baseParseSubscript(startTokenIndex, noCalls, stopState);
}
}

/** Set 'state.stop = true' to indicate that we should stop parsing subscripts. */
export function baseParseSubscript(noCalls: boolean, stopState: StopState): void {
export function baseParseSubscript(
startTokenIndex: number,
noCalls: boolean,
stopState: StopState,
): void {
if (!noCalls && eat(tt.doubleColon)) {
parseNoCallExpr();
stopState.stop = true;
parseSubscripts(noCalls);
// Propagate startTokenIndex so that `a::b?.()` will keep `a` as the first token. We may want
// to revisit this in the future when fully supporting bind syntax.
parseSubscripts(startTokenIndex, noCalls);
} else if (match(tt.questionDot)) {
state.tokens[startTokenIndex].isOptionalChainStart = true;
if (noCalls && lookaheadType() === tt.parenL) {
stopState.stop = true;
return;
}
next();
state.tokens[state.tokens.length - 1].subscriptStartIndex = startTokenIndex;

if (eat(tt.bracketL)) {
parseExpression();
Expand All @@ -311,17 +325,20 @@ export function baseParseSubscript(noCalls: boolean, stopState: StopState): void
parseIdentifier();
}
} else if (eat(tt.dot)) {
state.tokens[state.tokens.length - 1].subscriptStartIndex = startTokenIndex;
parseMaybePrivateName();
} else if (eat(tt.bracketL)) {
state.tokens[state.tokens.length - 1].subscriptStartIndex = startTokenIndex;
parseExpression();
expect(tt.bracketR);
} else if (!noCalls && match(tt.parenL)) {
if (atPossibleAsync()) {
// We see "async", but it's possible it's a usage of the name "async". Parse as if it's a
// function call, and if we see an arrow later, backtrack and re-parse as a parameter list.
const snapshot = state.snapshot();
const startTokenIndex = state.tokens.length;
const asyncStartTokenIndex = state.tokens.length;
next();
state.tokens[state.tokens.length - 1].subscriptStartIndex = startTokenIndex;

const callContextId = getNextContextId();

Expand All @@ -336,10 +353,11 @@ export function baseParseSubscript(noCalls: boolean, stopState: StopState): void
state.scopeDepth++;

parseFunctionParams();
parseAsyncArrowFromCallExpression(startTokenIndex);
parseAsyncArrowFromCallExpression(asyncStartTokenIndex);
}
} else {
next();
state.tokens[state.tokens.length - 1].subscriptStartIndex = startTokenIndex;
const callContextId = getNextContextId();
state.tokens[state.tokens.length - 1].contextId = callContextId;
parseCallExpressionArguments();
Expand Down Expand Up @@ -395,8 +413,9 @@ function parseAsyncArrowFromCallExpression(startTokenIndex: number): void {
// Parse a no-call expression (like argument of `new` or `::` operators).

function parseNoCallExpr(): void {
const startTokenIndex = state.tokens.length;
parseExprAtom();
parseSubscripts(true);
parseSubscripts(startTokenIndex, true);
}

// Parse an atomic expression — either a single token that is an
Expand Down
50 changes: 50 additions & 0 deletions src/transformers/OptionalChainingNullishTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import NameManager from "../NameManager";
import {TokenType as tt} from "../parser/tokenizer/types";
import TokenProcessor from "../TokenProcessor";
import Transformer from "./Transformer";

/**
* Transformer supporting the optional chaining and nullish coalescing operators.
*
* Tech plan here:
* https://github.com/alangpierce/sucrase/wiki/Sucrase-Optional-Chaining-and-Nullish-Coalescing-Technical-Plan
*
* The prefix and suffix code snippets are handled by TokenProcessor, and this transformer handles
* the operators themselves.
*/
export default class OptionalChainingNullishTransformer extends Transformer {
constructor(readonly tokens: TokenProcessor, readonly nameManager: NameManager) {
super();
}

process(): boolean {
if (this.tokens.matches1(tt.nullishCoalescing)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(", () =>");
return true;
}
const token = this.tokens.currentToken();
if (
token.subscriptStartIndex != null &&
this.tokens.tokens[token.subscriptStartIndex].isOptionalChainStart
) {
const param = this.nameManager.claimFreeName("_");
if (this.tokens.matches2(tt.questionDot, tt.parenL)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalCall', ${param} => ${param}`);
} else if (this.tokens.matches2(tt.questionDot, tt.bracketL)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${param} => ${param}`);
} else if (this.tokens.matches1(tt.questionDot)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${param} => ${param}.`);
} else if (this.tokens.matches1(tt.dot)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${param} => ${param}.`);
} else if (this.tokens.matches1(tt.bracketL)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${param} => ${param}[`);
} else if (this.tokens.matches1(tt.parenL)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'call', ${param} => ${param}(`);
} else {
throw new Error("Unexpected subscript operator in optional chain.");
}
return true;
}
return false;
}
}
8 changes: 4 additions & 4 deletions src/transformers/RootTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import FlowTransformer from "./FlowTransformer";
import JSXTransformer from "./JSXTransformer";
import NumericSeparatorTransformer from "./NumericSeparatorTransformer";
import OptionalCatchBindingTransformer from "./OptionalCatchBindingTransformer";
import OptionalChainingNullishTransformer from "./OptionalChainingNullishTransformer";
import ReactDisplayNameTransformer from "./ReactDisplayNameTransformer";
import ReactHotLoaderTransformer from "./ReactHotLoaderTransformer";
import Transformer from "./Transformer";
Expand Down Expand Up @@ -38,6 +39,9 @@ export default class RootTransformer {
this.isImportsTransformEnabled = transforms.includes("imports");
this.isReactHotLoaderTransformEnabled = transforms.includes("react-hot-loader");

this.transformers.push(
new OptionalChainingNullishTransformer(tokenProcessor, this.nameManager),
);
this.transformers.push(new NumericSeparatorTransformer(tokenProcessor));
this.transformers.push(new OptionalCatchBindingTransformer(tokenProcessor, this.nameManager));
if (transforms.includes("jsx")) {
Expand Down Expand Up @@ -151,10 +155,6 @@ export default class RootTransformer {
}

processToken(): void {
if (this.tokens.matches1(tt.nullishCoalescing)) {
this.tokens.replaceTokenTrimmingLeftWhitespace(", () =>");
return;
}
if (this.tokens.matches1(tt._class)) {
this.processClass();
return;
Expand Down
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
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
2:11-2:14 name log 0 0 0
2:14-2:15 ( ( 0 1 0 0
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 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 `,
);
});
});
9 changes: 9 additions & 0 deletions test/prefixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,12 @@ 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 OPTIONAL_CHAIN_PREFIX = ` function _optionalChain(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 = fn(value); } \
else if (op === 'call' || op === 'optionalCall') { \
value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; \
} } return value; }`;