From 4c0658fcc7759da3a31f8fcb0e9903c3eb5730af Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Tue, 12 Apr 2022 11:09:58 -0400 Subject: [PATCH] Optional chaining (#546) * Lexer support for optional chaining * Parsing and transpile support * Fix optional chaning indexed get and ternary * re-enable failing tests. * Addresses PR items * Remove ?( * Fixes for optional chaining tokens. * Fixes * another optional chain vs ternary test * Add disclaimer to ternary docs * add transpile tests for ?@ and @( * Re-enable simple consequents tests * Fix leading ? for print statements --- docs/ternary-operator.md | 50 +++++++++- src/files/tests/optionalChaning.spec.ts | 94 ++++++++++++++++++ src/lexer/Lexer.spec.ts | 73 +++++++++++++- src/lexer/Lexer.ts | 33 +++++++ src/lexer/TokenKind.ts | 5 +- src/parser/Expression.ts | 28 ++++-- src/parser/Parser.spec.ts | 97 ++++++++++++++++++- src/parser/Parser.ts | 55 +++++++++-- .../expression/TernaryExpression.spec.ts | 8 +- src/util.ts | 37 +++++++ 10 files changed, 449 insertions(+), 31 deletions(-) create mode 100644 src/files/tests/optionalChaning.spec.ts diff --git a/docs/ternary-operator.md b/docs/ternary-operator.md index 17809a02c..d21d0bc83 100644 --- a/docs/ternary-operator.md +++ b/docs/ternary-operator.md @@ -1,5 +1,9 @@ # Ternary (Conditional) Operator: ? -The ternary (conditional) operator is the only BrighterScript operator that takes three operands: a condition followed by a question mark (?), then an expression to execute (consequent) if the condition is true followed by a colon (:), and finally the expression to execute (alternate) if the condition is false. This operator is frequently used as a shortcut for the if statement. It can be used in assignments, and in any other place where an expression is valid. Due to ambiguity in the brightscript syntax, ternary operators cannot be used as standalone statements. See the [No standalone statements](#no-standalone-statements) for more information. +The ternary (conditional) operator is the only BrighterScript operator that takes three operands: a condition followed by a question mark (?), then an expression to execute (consequent) if the condition is true followed by a colon (:), and finally the expression to execute (alternate) if the condition is false. This operator is frequently used as a shortcut for the if statement. It can be used in assignments, and in any other place where an expression is valid. Due to ambiguity in the brightscript syntax, ternary operators cannot be used as standalone statements. See the [No standalone statements](#no-standalone-statements) section for more information. + +## Warning +

The optional chaining operator was added to the BrightScript runtime in Roku OS 11, which introduced a slight limitation to the BrighterScript ternary operator. As such, all ternary expressions must have a space to the right of the question mark when followed by [ or (. See the optional chaning section for more information. +

## Basic usage @@ -102,7 +106,7 @@ a = (function(__bsCondition, getNoNameMessage, m, user) end function)(user = invalid, getNoNameMessage, m, user) ``` -### nested scope protection +### Nested Scope Protection The scope protection works for multiple levels as well ```BrighterScript m.count = 1 @@ -174,3 +178,45 @@ a = (myValue ? "a" : "b'") ``` This ambiguity is why BrighterScript does not allow for standalone ternary statements. + + +## Optional Chaining considerations +The [optional chaining operator](https://developer.roku.com/docs/references/brightscript/language/expressions-variables-types.md#optional-chaining-operators) was added to the BrightScript runtime in Roku OS 11, which introduced a slight limitation to the BrighterScript ternary operator. As such, all ternary expressions must have a space to the right of the question mark when followed by `[` or `(`. If there's no space, then it's optional chaining. + +For example: + +*Ternary:* +```brightscript +data = isTrue ? ["key"] : getFalseData() +data = isTrue ? (1 + 2) : getFalseData() +``` +*Optional chaining:* +```brightscript +data = isTrue ?["key"] : getFalseData() +data = isTrue ?(1 + 2) : getFalseData() +``` + +The colon symbol `:` can be used in BrightScript to include multiple statements on a single line. So, let's look at the first ternary statement again. +```brightscript +data = isTrue ? ["key"] : getFalseData() +``` + +This can be logically rewritten as: +```brightscript +if isTrue then + data = ["key"] +else + data = getFalseData() +``` + +Now consider the first optional chaining example: +```brightscript +data = isTrue ?["key"] : getFalseData() +``` +This can be logically rewritten as: +```brightscript +data = isTrue ?["key"] +getFalseData() +``` + +Both examples have valid use cases, so just remember that a single space could result in significantly different code output. diff --git a/src/files/tests/optionalChaning.spec.ts b/src/files/tests/optionalChaning.spec.ts new file mode 100644 index 000000000..e25f82b68 --- /dev/null +++ b/src/files/tests/optionalChaning.spec.ts @@ -0,0 +1,94 @@ +import * as sinonImport from 'sinon'; +import * as fsExtra from 'fs-extra'; +import { Program } from '../../Program'; +import { standardizePath as s } from '../../util'; +import { getTestTranspile } from '../../testHelpers.spec'; + +let sinon = sinonImport.createSandbox(); +let tmpPath = s`${process.cwd()}/.tmp`; +let rootDir = s`${tmpPath}/rootDir`; +let stagingFolderPath = s`${tmpPath}/staging`; + +describe('optional chaining', () => { + let program: Program; + const testTranspile = getTestTranspile(() => [program, rootDir]); + + beforeEach(() => { + fsExtra.ensureDirSync(tmpPath); + fsExtra.emptyDirSync(tmpPath); + program = new Program({ + rootDir: rootDir, + stagingFolderPath: stagingFolderPath + }); + }); + afterEach(() => { + sinon.restore(); + fsExtra.ensureDirSync(tmpPath); + fsExtra.emptyDirSync(tmpPath); + program.dispose(); + }); + + it('transpiles ?. properly', () => { + testTranspile(` + sub main() + print m?.value + end sub + `); + }); + + it('transpiles ?[ properly', () => { + testTranspile(` + sub main() + print m?["value"] + end sub + `); + }); + + it(`transpiles '?.[`, () => { + testTranspile(` + sub main() + print m?["value"] + end sub + `); + }); + + it(`transpiles '?@`, () => { + testTranspile(` + sub main() + print xmlThing?@someAttr + end sub + `); + }); + + it(`transpiles '?(`, () => { + testTranspile(` + sub main() + localFunc = sub() + end sub + print localFunc?() + print m.someFunc?() + end sub + `); + }); + + it('transpiles various use cases', () => { + testTranspile(` + print arr?.["0"] + print arr?.value + print assocArray?.[0] + print assocArray?.getName()?.first?.second + print createObject("roByteArray")?.value + print createObject("roByteArray")?["0"] + print createObject("roList")?.value + print createObject("roList")?["0"] + print createObject("roXmlList")?["0"] + print createObject("roDateTime")?.value + print createObject("roDateTime")?.GetTimeZoneOffset + print createObject("roSGNode", "Node")?[0] + print pi?.first?.second + print success?.first?.second + print a.b.xmlThing?@someAttr + print a.b.localFunc?() + `); + }); +}); diff --git a/src/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts index 7c044135d..d2b3a4381 100644 --- a/src/lexer/Lexer.spec.ts +++ b/src/lexer/Lexer.spec.ts @@ -20,6 +20,67 @@ describe('lexer', () => { ]); }); + it('recognizes the question mark operator in various contexts', () => { + expectKinds('? ?? ?. ?[ ?.[ ?( ?@', [ + TokenKind.Question, + TokenKind.QuestionQuestion, + TokenKind.QuestionDot, + TokenKind.QuestionLeftSquare, + TokenKind.QuestionDot, + TokenKind.LeftSquareBracket, + TokenKind.QuestionLeftParen, + TokenKind.QuestionAt + ]); + }); + + it('separates optional chain characters and LeftSquare when found at beginning of statement locations', () => { + //a statement starting with a question mark is actually a print statement, so we need to keep the ? separate from [ + expectKinds(`?[ ?[ : ?[ ?[`, [ + TokenKind.Question, + TokenKind.LeftSquareBracket, + TokenKind.QuestionLeftSquare, + TokenKind.Colon, + TokenKind.Question, + TokenKind.LeftSquareBracket, + TokenKind.QuestionLeftSquare + ]); + }); + + it('separates optional chain characters and LeftParen when found at beginning of statement locations', () => { + //a statement starting with a question mark is actually a print statement, so we need to keep the ? separate from [ + expectKinds(`?( ?( : ?( ?(`, [ + TokenKind.Question, + TokenKind.LeftParen, + TokenKind.QuestionLeftParen, + TokenKind.Colon, + TokenKind.Question, + TokenKind.LeftParen, + TokenKind.QuestionLeftParen + ]); + }); + + it('handles QuestionDot and Square properly', () => { + expectKinds('?.[ ?. [', [ + TokenKind.QuestionDot, + TokenKind.LeftSquareBracket, + TokenKind.QuestionDot, + TokenKind.LeftSquareBracket + ]); + }); + + it('does not make conditional chaining tokens with space between', () => { + expectKinds('? . ? [ ? ( ? @', [ + TokenKind.Question, + TokenKind.Dot, + TokenKind.Question, + TokenKind.LeftSquareBracket, + TokenKind.Question, + TokenKind.LeftParen, + TokenKind.Question, + TokenKind.At + ]); + }); + it('recognizes the callfunc operator', () => { let { tokens } = Lexer.scan('@.'); expect(tokens[0].kind).to.equal(TokenKind.Callfunc); @@ -35,11 +96,6 @@ describe('lexer', () => { expect(tokens[0].kind).to.eql(TokenKind.Library); }); - it('recognizes the question mark operator', () => { - let { tokens } = Lexer.scan('?'); - expect(tokens[0].kind).to.equal(TokenKind.Question); - }); - it('produces an at symbol token', () => { let { tokens } = Lexer.scan('@'); expect(tokens[0].kind).to.equal(TokenKind.At); @@ -1306,3 +1362,10 @@ describe('lexer', () => { }); }); }); + +function expectKinds(text: string, tokenKinds: TokenKind[]) { + let actual = Lexer.scan(text).tokens.map(x => x.kind); + //remove the EOF token + actual.pop(); + expect(actual).to.eql(tokenKinds); +} diff --git a/src/lexer/Lexer.ts b/src/lexer/Lexer.ts index bc1f38658..4d40beeab 100644 --- a/src/lexer/Lexer.ts +++ b/src/lexer/Lexer.ts @@ -277,12 +277,45 @@ export class Lexer { if (this.peek() === '?') { this.advance(); this.addToken(TokenKind.QuestionQuestion); + } else if (this.peek() === '.') { + this.advance(); + this.addToken(TokenKind.QuestionDot); + } else if (this.peek() === '[' && !this.isStartOfStatement()) { + this.advance(); + this.addToken(TokenKind.QuestionLeftSquare); + } else if (this.peek() === '(' && !this.isStartOfStatement()) { + this.advance(); + this.addToken(TokenKind.QuestionLeftParen); + } else if (this.peek() === '@') { + this.advance(); + this.addToken(TokenKind.QuestionAt); } else { this.addToken(TokenKind.Question); } } }; + /** + * Determine if the current position is at the beginning of a statement. + * This means the token to the left, excluding whitespace, is either a newline or a colon + */ + private isStartOfStatement() { + for (let i = this.tokens.length - 1; i >= 0; i--) { + const token = this.tokens[i]; + //skip whitespace + if (token.kind === TokenKind.Whitespace) { + continue; + } + if (token.kind === TokenKind.Newline || token.kind === TokenKind.Colon) { + return true; + } else { + return false; + } + } + //if we got here, there were no tokens or only whitespace, so it's the start of the file + return true; + } + /** * Map for looking up token kinds based solely on a single character. * Should be used in conjunction with `tokenFunctionMap` diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts index d617bea53..73a0f4eb8 100644 --- a/src/lexer/TokenKind.ts +++ b/src/lexer/TokenKind.ts @@ -77,7 +77,10 @@ export enum TokenKind { Question = 'Question', // ? QuestionQuestion = 'QuestionQuestion', // ?? BackTick = 'BackTick', // ` - + QuestionDot = 'QuestionDot', // ?. + QuestionLeftSquare = 'QuestionLeftSquare', // ?[ + QuestionLeftParen = 'QuestionLeftParen', // ?( + QuestionAt = 'QuestionAt', // ?@ // conditional compilation HashIf = 'HashIf', // #if diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index f4f3b7128..bfbaeb983 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -69,6 +69,9 @@ export class CallExpression extends Expression { constructor( readonly callee: Expression, + /** + * Can either be `(`, or `?(` for optional chaining + */ readonly openingParen: Token, readonly closingParen: Token, readonly args: Expression[], @@ -368,6 +371,9 @@ export class DottedGetExpression extends Expression { constructor( readonly obj: Expression, readonly name: Identifier, + /** + * Can either be `.`, or `?.` for optional chaining + */ readonly dot: Token ) { super(); @@ -383,7 +389,7 @@ export class DottedGetExpression extends Expression { } else { return [ ...this.obj.transpile(state), - '.', + state.transpileToken(this.dot), state.transpileToken(this.name) ]; } @@ -400,6 +406,9 @@ export class XmlAttributeGetExpression extends Expression { constructor( readonly obj: Expression, readonly name: Identifier, + /** + * Can either be `@`, or `?@` for optional chaining + */ readonly at: Token ) { super(); @@ -411,7 +420,7 @@ export class XmlAttributeGetExpression extends Expression { transpile(state: BrsTranspileState) { return [ ...this.obj.transpile(state), - '@', + state.transpileToken(this.at), state.transpileToken(this.name) ]; } @@ -425,13 +434,17 @@ export class XmlAttributeGetExpression extends Expression { export class IndexedGetExpression extends Expression { constructor( - readonly obj: Expression, - readonly index: Expression, - readonly openingSquare: Token, - readonly closingSquare: Token + public obj: Expression, + public index: Expression, + /** + * Can either be `[` or `?[`. If `?.[` is used, this will be `[` and `optionalChainingToken` will be `?.` + */ + public openingSquare: Token, + public closingSquare: Token, + public questionDotToken?: Token // ? or ?. ) { super(); - this.range = util.createRangeFromPositions(this.obj.range.start, this.closingSquare.range.end); + this.range = util.createBoundingRange(this.obj, this.openingSquare, this.questionDotToken, this.openingSquare, this.index, this.closingSquare); } public readonly range: Range; @@ -439,6 +452,7 @@ export class IndexedGetExpression extends Expression { transpile(state: BrsTranspileState) { return [ ...this.obj.transpile(state), + this.questionDotToken ? state.transpileToken(this.questionDotToken) : '', state.transpileToken(this.openingSquare), ...this.index.transpile(state), state.transpileToken(this.closingSquare) diff --git a/src/parser/Parser.spec.ts b/src/parser/Parser.spec.ts index 64971237c..161888a7a 100644 --- a/src/parser/Parser.spec.ts +++ b/src/parser/Parser.spec.ts @@ -1,14 +1,14 @@ import { expect, assert } from 'chai'; import { Lexer } from '../lexer/Lexer'; -import { ReservedWords } from '../lexer/TokenKind'; +import { ReservedWords, TokenKind } from '../lexer/TokenKind'; import type { Expression } from './Expression'; -import { DottedGetExpression, XmlAttributeGetExpression, CallfuncExpression, AnnotationExpression, CallExpression, FunctionExpression } from './Expression'; +import { TernaryExpression, NewExpression, IndexedGetExpression, DottedGetExpression, XmlAttributeGetExpression, CallfuncExpression, AnnotationExpression, CallExpression, FunctionExpression } from './Expression'; import { Parser, ParseMode } from './Parser'; import type { AssignmentStatement, ClassStatement, Statement } from './Statement'; import { PrintStatement, FunctionStatement, NamespaceStatement, ImportStatement } from './Statement'; import { Range } from 'vscode-languageserver'; import { DiagnosticMessages } from '../DiagnosticMessages'; -import { isBlock, isCommentStatement, isFunctionStatement, isIfStatement } from '../astUtils/reflection'; +import { isBlock, isCommentStatement, isFunctionStatement, isIfStatement, isIndexedGetExpression } from '../astUtils/reflection'; import { expectZeroDiagnostics } from '../testHelpers.spec'; import { BrsTranspileState } from './BrsTranspileState'; import { SourceNode } from 'source-map'; @@ -139,6 +139,97 @@ describe('parser', () => { }); }); + describe('optional chaining operator', () => { + function getExpression(text: string, options?: { matcher?: any; parseMode?: ParseMode }) { + const parser = parse(text, options?.parseMode); + expectZeroDiagnostics(parser); + const expressions = [...parser.references.expressions]; + if (options?.matcher) { + return expressions.find(options.matcher) as unknown as T; + } else { + return expressions[0] as unknown as T; + } + } + it('works for ?.', () => { + const expression = getExpression(`value = person?.name`); + expect(expression).to.be.instanceOf(DottedGetExpression); + expect(expression.dot.kind).to.eql(TokenKind.QuestionDot); + }); + + it('works for ?[', () => { + const expression = getExpression(`value = person?["name"]`, { matcher: isIndexedGetExpression }); + expect(expression).to.be.instanceOf(IndexedGetExpression); + expect(expression.openingSquare.kind).to.eql(TokenKind.QuestionLeftSquare); + expect(expression.questionDotToken).not.to.exist; + }); + + it('works for ?.[', () => { + const expression = getExpression(`value = person?.["name"]`, { matcher: isIndexedGetExpression }); + expect(expression).to.be.instanceOf(IndexedGetExpression); + expect(expression.openingSquare.kind).to.eql(TokenKind.LeftSquareBracket); + expect(expression.questionDotToken?.kind).to.eql(TokenKind.QuestionDot); + }); + + it('works for ?@', () => { + const expression = getExpression(`value = someXml?@someAttr`); + expect(expression).to.be.instanceOf(XmlAttributeGetExpression); + expect(expression.at.kind).to.eql(TokenKind.QuestionAt); + }); + + it('works for ?(', () => { + const expression = getExpression(`value = person.getName?()`); + expect(expression).to.be.instanceOf(CallExpression); + expect(expression.openingParen.kind).to.eql(TokenKind.QuestionLeftParen); + }); + + it('works for print statements using question mark', () => { + const { statements } = parse(` + ?[1] + ?(1+1) + `); + expect(statements[0]).to.be.instanceOf(PrintStatement); + expect(statements[1]).to.be.instanceOf(PrintStatement); + }); + + //TODO enable this once we properly parse IIFEs + it.skip('works for ?( in anonymous function', () => { + const expression = getExpression(`thing = (function() : end function)?()`); + expect(expression).to.be.instanceOf(CallExpression); + expect(expression.openingParen.kind).to.eql(TokenKind.QuestionLeftParen); + }); + + it('works for ?( in new call', () => { + const expression = getExpression(`thing = new Person?()`, { parseMode: ParseMode.BrighterScript }); + expect(expression).to.be.instanceOf(NewExpression); + expect(expression.call.openingParen.kind).to.eql(TokenKind.QuestionLeftParen); + }); + + it('distinguishes between optional chaining and ternary expression', () => { + const parser = parse(` + sub main() + name = person?["name"] + isTrue = true + key = isTrue ? ["name"] : ["age"] + end sub + `, ParseMode.BrighterScript); + expect(parser.references.assignmentStatements[0].value).is.instanceof(IndexedGetExpression); + expect(parser.references.assignmentStatements[2].value).is.instanceof(TernaryExpression); + }); + + it('distinguishes between optional chaining and ternary expression', () => { + const parser = parse(` + sub main() + 'optional chain. the lack of whitespace between ? and [ matters + key = isTrue ?["name"] : getDefault() + 'ternary + key = isTrue ? ["name"] : getDefault() + end sub + `, ParseMode.BrighterScript); + expect(parser.references.assignmentStatements[0].value).is.instanceof(IndexedGetExpression); + expect(parser.references.assignmentStatements[1].value).is.instanceof(TernaryExpression); + }); + }); + describe('diagnostic locations', () => { it('tracks basic diagnostic locations', () => { expect(parse(` diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index 466bf5a55..9c2fc59f8 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -2316,6 +2316,7 @@ export class Parser { private indexedGet(expr: Expression) { let openingSquare = this.previous(); + let questionDotToken = this.getMatchingTokenAtOffset(-2, TokenKind.QuestionDot); while (this.match(TokenKind.Newline)) { } let index = this.expression(); @@ -2326,7 +2327,7 @@ export class Parser { TokenKind.RightSquareBracket ); - return new IndexedGetExpression(expr, index, openingSquare, closingSquare); + return new IndexedGetExpression(expr, index, openingSquare, closingSquare, questionDotToken); } private newExpression() { @@ -2336,7 +2337,8 @@ export class Parser { let nameExpr = this.getNamespacedVariableNameExpression(); let leftParen = this.consume( DiagnosticMessages.unexpectedToken(this.peek().text), - TokenKind.LeftParen + TokenKind.LeftParen, + TokenKind.QuestionLeftParen ); let call = this.finishCall(leftParen, nameExpr); //pop the call from the callExpressions list because this is technically something else @@ -2369,17 +2371,20 @@ export class Parser { //an expression to keep for _references let referenceCallExpression: Expression; while (true) { - if (this.match(TokenKind.LeftParen)) { + if (this.matchAny(TokenKind.LeftParen, TokenKind.QuestionLeftParen)) { expr = this.finishCall(this.previous(), expr); //store this call expression in references referenceCallExpression = expr; - } else if (this.match(TokenKind.LeftSquareBracket)) { + + } else if (this.matchAny(TokenKind.LeftSquareBracket, TokenKind.QuestionLeftSquare) || this.matchSequence(TokenKind.QuestionDot, TokenKind.LeftSquareBracket)) { expr = this.indexedGet(expr); + } else if (this.match(TokenKind.Callfunc)) { expr = this.callfunc(expr); //store this callfunc expression in references referenceCallExpression = expr; - } else if (this.match(TokenKind.Dot)) { + + } else if (this.matchAny(TokenKind.Dot, TokenKind.QuestionDot)) { if (this.match(TokenKind.LeftSquareBracket)) { expr = this.indexedGet(expr); } else { @@ -2396,7 +2401,8 @@ export class Parser { this.addPropertyHints(name); } - } else if (this.check(TokenKind.At)) { + + } else if (this.checkAny(TokenKind.At, TokenKind.QuestionAt)) { let dot = this.advance(); let name = this.consume( DiagnosticMessages.expectedAttributeNameAfterAtSymbol(), @@ -2410,6 +2416,7 @@ export class Parser { expr = new XmlAttributeGetExpression(expr, name as Identifier, dot); //only allow a single `@` expression break; + } else { break; } @@ -2423,8 +2430,7 @@ export class Parser { private finishCall(openingParen: Token, callee: Expression, addToCallExpressionList = true) { let args = [] as Expression[]; - while (this.match(TokenKind.Newline)) { - } + while (this.match(TokenKind.Newline)) { } if (!this.check(TokenKind.RightParen)) { do { @@ -2520,7 +2526,7 @@ export class Parser { ); return new GroupingExpression({ left: left, right: right }, expr); - case this.match(TokenKind.LeftSquareBracket): + case this.matchAny(TokenKind.LeftSquareBracket): return this.arrayLiteral(); case this.match(TokenKind.LeftCurlyBrace): @@ -2721,6 +2727,21 @@ export class Parser { return false; } + /** + * If the next series of tokens matches the given set of tokens, pop them all + * @param tokenKinds + */ + private matchSequence(...tokenKinds: TokenKind[]) { + const endIndex = this.current + tokenKinds.length; + for (let i = 0; i < tokenKinds.length; i++) { + if (tokenKinds[i] !== this.tokens[this.current + i]?.kind) { + return false; + } + } + this.current = endIndex; + return true; + } + /** * Get next token matching a specified list, or fail with an error */ @@ -2850,6 +2871,22 @@ export class Parser { return this.tokens[this.current - 1]; } + /** + * Get the token that is {offset} indexes away from {this.current} + * @param offset the number of index steps away from current index to fetch + * @param tokenKinds the desired token must match one of these + * @example + * getToken(-1); //returns the previous token. + * getToken(0); //returns current token. + * getToken(1); //returns next token + */ + private getMatchingTokenAtOffset(offset: number, ...tokenKinds: TokenKind[]): Token { + const token = this.tokens[this.current + offset]; + if (tokenKinds.includes(token.kind)) { + return token; + } + } + private synchronize() { this.advance(); // skip the erroneous token diff --git a/src/parser/tests/expression/TernaryExpression.spec.ts b/src/parser/tests/expression/TernaryExpression.spec.ts index 889aba1ae..f2df24033 100644 --- a/src/parser/tests/expression/TernaryExpression.spec.ts +++ b/src/parser/tests/expression/TernaryExpression.spec.ts @@ -290,13 +290,13 @@ describe('ternary expressions', () => { ); testTranspile( - `a = user = invalid ? [] : "logged in"`, - `a = bslib_ternary(user = invalid, [], "logged in")` + `a = user = invalid ? {} : "logged in"`, + `a = bslib_ternary(user = invalid, {}, "logged in")` ); testTranspile( - `a = user = invalid ? {} : "logged in"`, - `a = bslib_ternary(user = invalid, {}, "logged in")` + `a = user = invalid ? [] : "logged in"`, + `a = bslib_ternary(user = invalid, [], "logged in")` ); }); diff --git a/src/util.ts b/src/util.ts index 76cef3a2f..dbb74399a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -893,6 +893,43 @@ export class Util { }; } + /** + * Given a list of ranges, create a range that starts with the first non-null lefthand range, and ends with the first non-null + * righthand range. Returns undefined if none of the items have a range. + */ + public createBoundingRange(...locatables: Array<{ range?: Range }>) { + let leftmostRange: Range; + let rightmostRange: Range; + + for (let i = 0; i < locatables.length; i++) { + //set the leftmost non-null-range item + const left = locatables[i]; + //the range might be a getter, so access it exactly once + const leftRange = left?.range; + if (!leftmostRange && leftRange) { + leftmostRange = leftRange; + } + + //set the rightmost non-null-range item + const right = locatables[locatables.length - 1 - i]; + //the range might be a getter, so access it exactly once + const rightRange = right?.range; + if (!rightmostRange && rightRange) { + rightmostRange = rightRange; + } + + //if we have both sides, quit + if (leftmostRange && rightmostRange) { + break; + } + } + if (leftmostRange) { + return this.createRangeFromPositions(leftmostRange.start, rightmostRange.end); + } else { + return undefined; + } + } + /** * Create a `Position` object. Prefer this over `Position.create` for performance reasons */