Repository: rokucommunity/brighterscript
Pull request #546 (2022-03-23 13:09:58)

Optional chaining
Adds syntax support for optional chaining. Since the brightscript runtime supports this natively in Roku OS 11, there's no need for any special transpile logic at this time. However, there are significant dangers of crashing in the native implementation, so I think we will still add a transpile step in the future to make this feature more useful.

This PR is just to support the syntax from a parser level.


Patch:
diff --git a/docs/ternary-operator.md b/docs/ternary-operator.md
index 17809a02..d21d0bc8 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
+<p style="background-color: #fdf8e3; color: #333; padding: 20px">The <a href="https://developer.roku.com/docs/references/brightscript/language/expressions-variables-types.md#optional-chaining-operators">optional chaining operator</a> was added to the BrightScript runtime in <a href="https://developer.roku.com/docs/developer-program/release-notes/roku-os-release-notes.md#roku-os-110">Roku OS 11</a>, 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 <b>[</b> or <b>(</b>. See the <a href="#">optional chaning</a> section for more information.
+</p>
 
 ## 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 <a href="https://developer.roku.com/docs/developer-program/release-notes/roku-os-release-notes.md#roku-os-110">Roku OS 11</a>, 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/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts
index 7c044135..d2b3a438 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 bc1f3865..4d40beea 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 d617bea5..73a0f4eb 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 f4f3b712..bfbaeb98 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 64971237..161888a7 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<T>(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<DottedGetExpression>(`value = person?.name`);
+            expect(expression).to.be.instanceOf(DottedGetExpression);
+            expect(expression.dot.kind).to.eql(TokenKind.QuestionDot);
+        });
+
+        it('works for ?[', () => {
+            const expression = getExpression<IndexedGetExpression>(`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<IndexedGetExpression>(`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<XmlAttributeGetExpression>(`value = someXml?@someAttr`);
+            expect(expression).to.be.instanceOf(XmlAttributeGetExpression);
+            expect(expression.at.kind).to.eql(TokenKind.QuestionAt);
+        });
+
+        it('works for ?(', () => {
+            const expression = getExpression<CallExpression>(`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<CallExpression>(`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<NewExpression>(`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 466bf5a5..9c2fc59f 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/util.ts b/src/util.ts
index 76cef3a2..dbb74399 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
      */


Test patch:
diff --git a/src/files/tests/optionalChaning.spec.ts b/src/files/tests/optionalChaning.spec.ts
new file mode 100644
index 00000000..e25f82b6
--- /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/parser/tests/expression/TernaryExpression.spec.ts b/src/parser/tests/expression/TernaryExpression.spec.ts
index 889aba1a..f2df2403 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")`
             );
         });