diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts index 760a11aa1..45ef9b1ab 100644 --- a/src/interpreter/index.ts +++ b/src/interpreter/index.ts @@ -1178,6 +1178,9 @@ export class Interpreter implements Expr.Visitor, Stmt.Visitor let args = expression.args.map(this.evaluate, this); if (!isBrsCallable(callee)) { + if (callee instanceof BrsInvalid && expression.optional) { + return callee; + } this.addError( new RuntimeError(RuntimeErrorDetail.NotAFunction, expression.closingParen.location) ); @@ -1295,21 +1298,31 @@ export class Interpreter implements Expr.Visitor, Stmt.Visitor let boxedSource = isBoxable(source) ? source.box() : source; if (boxedSource instanceof BrsComponent) { + // This check is supposed to be placed below the try/catch block, + // but it's here to mimic the behavior of Roku, if they fix, we move it. + if (source instanceof BrsInvalid && expression.optional) { + return source; + } try { - return boxedSource.getMethod(expression.name.text) || BrsInvalid.Instance; + const method = boxedSource.getMethod(expression.name.text); + if (method) { + return method; + } } catch (err: any) { this.addError(new BrsError(err.message, expression.name.location)); } - } else { - this.addError( - new RuntimeError(RuntimeErrorDetail.DotOnNonObject, expression.name.location) - ); } + this.addError( + new RuntimeError(RuntimeErrorDetail.DotOnNonObject, expression.name.location) + ); } visitIndexedGet(expression: Expr.IndexedGet): BrsType { let source = this.evaluate(expression.obj); if (!isIterable(source)) { + if (source instanceof BrsInvalid && expression.optional) { + return source; + } this.addError(new RuntimeError(RuntimeErrorDetail.UndimmedArray, expression.location)); } diff --git a/src/lexer/Lexer.ts b/src/lexer/Lexer.ts index c37baef6f..d519d53eb 100644 --- a/src/lexer/Lexer.ts +++ b/src/lexer/Lexer.ts @@ -250,7 +250,7 @@ export class Lexer { break; case "[": advance(); - addToken(Lexeme.LeftBrace); + addToken(Lexeme.LeftSquare); break; default: addToken(Lexeme.Print); diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index 225e0c51d..7b359fcf0 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -51,7 +51,8 @@ export class Call extends AstNode implements Expression { constructor( readonly callee: Expression, readonly closingParen: Token, - readonly args: Expression[] + readonly args: Expression[], + readonly optional: boolean = false ) { super("Call"); } @@ -94,7 +95,11 @@ export class Function extends AstNode implements Expression { } export class DottedGet extends AstNode implements Expression { - constructor(readonly obj: Expression, readonly name: Identifier) { + constructor( + readonly obj: Expression, + readonly name: Identifier, + readonly optional: boolean = false + ) { super("DottedGet"); } @@ -115,7 +120,8 @@ export class IndexedGet extends AstNode implements Expression { constructor( readonly obj: Expression, readonly indexes: Expression[], - readonly closingSquare: Token + readonly closingSquare: Token, + readonly optional: boolean = false ) { super("IndexedGet"); } diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index 4f9d06d95..8dc84a392 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -1477,6 +1477,7 @@ export class Parser { let expr = primary(); function indexedGet() { + const optional = previous().text === "?["; let elements: Expression[] = []; while (match(Lexeme.Newline)); @@ -1502,11 +1503,9 @@ export class Parser { Lexeme.RightSquare ); - expr = new Expr.IndexedGet(expr, elements, closingSquare); + expr = new Expr.IndexedGet(expr, elements, closingSquare, optional); } - function dottedGet() {} - while (true) { if (match(Lexeme.LeftParen)) { expr = finishCall(expr); @@ -1516,6 +1515,7 @@ export class Parser { if (match(Lexeme.LeftSquare)) { indexedGet(); } else { + const optional = previous().text === "?."; while (match(Lexeme.Newline)); let name = consume( @@ -1527,7 +1527,7 @@ export class Parser { // force it into an identifier so the AST makes some sense name.kind = Lexeme.Identifier; - expr = new Expr.DottedGet(expr, name as Identifier); + expr = new Expr.DottedGet(expr, name as Identifier, optional); } } else { break; @@ -1538,6 +1538,7 @@ export class Parser { } function finishCall(callee: Expression): Expression { + const optional = previous().text === "?("; let args = []; while (match(Lexeme.Newline)); @@ -1561,7 +1562,7 @@ export class Parser { Lexeme.RightParen ); - return new Expr.Call(callee, closingParen, args); + return new Expr.Call(callee, closingParen, args, optional); } function primary(): Expression { diff --git a/test/e2e/Syntax.test.js b/test/e2e/Syntax.test.js index c13876f73..b3c8893aa 100644 --- a/test/e2e/Syntax.test.js +++ b/test/e2e/Syntax.test.js @@ -131,6 +131,20 @@ describe("end to end syntax", () => { ]); }); + test("optional-chaining-operators.brs", async () => { + await execute([resourceFile("optional-chaining-operators.brs")], outputStreams); + + expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([ + "invalid", + "invalid", + "invalid", + "invalid", + "invalid", + "invalid", + "invalid", + ]); + }); + test("conditionals.brs", async () => { await execute([resourceFile("conditionals.brs")], outputStreams); diff --git a/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs index 7b2b4ea09..330165ce3 100644 --- a/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs +++ b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs @@ -53,7 +53,7 @@ sub init() end sub function getMessage(node as object, event as object) as string - print "*" + node.id + "* " + node.focusedChild.id.toStr() + " " + event.getData().id.toStr() + " " + node.isInFocusChain().toStr() + print "*" + node.id + "* " + node.focusedChild?.id.toStr() + " " + event.getData()?.id.toStr() + " " + node.isInFocusChain().toStr() end function sub onFocusTop(event as object) diff --git a/test/e2e/resources/optional-chaining-operators.brs b/test/e2e/resources/optional-chaining-operators.brs new file mode 100644 index 000000000..4678bfd09 --- /dev/null +++ b/test/e2e/resources/optional-chaining-operators.brs @@ -0,0 +1,11 @@ +sub Main() + thing = invalid + print thing?.property + print thing?.functionCall2?() + print thing?[0] + print thing?[0]?.property + print thing?[0]?.functionCall2?() + print thing?.functionCall?(thing?[0]?.property, thing?[0]?.functionCall2?()) + node = {} + print node?.focusedChild?.id.toStr() +end sub \ No newline at end of file diff --git a/test/parser/controlFlow/__snapshots__/If.test.js.snap b/test/parser/controlFlow/__snapshots__/If.test.js.snap index 41bfb4884..6da19a2fb 100644 --- a/test/parser/controlFlow/__snapshots__/If.test.js.snap +++ b/test/parser/controlFlow/__snapshots__/If.test.js.snap @@ -2691,6 +2691,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, "type": "DottedSet", @@ -2786,6 +2787,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, "type": "DottedSet", @@ -3564,8 +3566,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, ], @@ -3606,6 +3610,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "right": Literal { @@ -3772,10 +3777,13 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, "closingParen": Object { @@ -3795,6 +3803,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, }, @@ -3862,8 +3871,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, ], @@ -3904,6 +3915,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "right": Literal { @@ -4050,8 +4062,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, }, @@ -4170,8 +4184,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, ], @@ -4231,8 +4247,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, "closingParen": Object { @@ -4252,6 +4270,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, }, @@ -4422,8 +4441,10 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "DottedGet", }, }, diff --git a/test/parser/expression/__snapshots__/Call.test.js.snap b/test/parser/expression/__snapshots__/Call.test.js.snap index 703c34770..363898996 100644 --- a/test/parser/expression/__snapshots__/Call.test.js.snap +++ b/test/parser/expression/__snapshots__/Call.test.js.snap @@ -75,6 +75,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "type": "Expression", @@ -122,6 +123,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "type": "Expression", @@ -533,6 +535,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "type": "Expression", diff --git a/test/parser/expression/__snapshots__/Function.test.js.snap b/test/parser/expression/__snapshots__/Function.test.js.snap index 819848b55..f5472dedf 100644 --- a/test/parser/expression/__snapshots__/Function.test.js.snap +++ b/test/parser/expression/__snapshots__/Function.test.js.snap @@ -2898,6 +2898,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, "type": "Expression", diff --git a/test/parser/expression/__snapshots__/Indexing.test.js.snap b/test/parser/expression/__snapshots__/Indexing.test.js.snap index 69cf3bc1f..431a14726 100644 --- a/test/parser/expression/__snapshots__/Indexing.test.js.snap +++ b/test/parser/expression/__snapshots__/Indexing.test.js.snap @@ -165,10 +165,13 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "IndexedGet", }, + "optional": false, "type": "IndexedGet", }, + "optional": false, "type": "IndexedGet", }, }, @@ -249,6 +252,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, }, @@ -382,10 +386,13 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, + "optional": false, "type": "IndexedGet", }, + "optional": false, "type": "DottedGet", }, }, @@ -485,6 +492,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "IndexedGet", }, }, @@ -565,6 +573,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, }, @@ -664,6 +673,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "IndexedGet", }, }, diff --git a/test/parser/statement/__snapshots__/Increment.test.js.snap b/test/parser/statement/__snapshots__/Increment.test.js.snap index a1b14fcec..1d218a814 100644 --- a/test/parser/statement/__snapshots__/Increment.test.js.snap +++ b/test/parser/statement/__snapshots__/Increment.test.js.snap @@ -192,6 +192,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "IndexedGet", }, }, @@ -297,6 +298,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, }, diff --git a/test/parser/statement/__snapshots__/ReturnStatement.test.js.snap b/test/parser/statement/__snapshots__/ReturnStatement.test.js.snap index cb5516f4f..fc6da7aa0 100644 --- a/test/parser/statement/__snapshots__/ReturnStatement.test.js.snap +++ b/test/parser/statement/__snapshots__/ReturnStatement.test.js.snap @@ -74,6 +74,7 @@ Array [ }, "text": ")", }, + "optional": false, "type": "Call", }, }, diff --git a/test/parser/statement/__snapshots__/Set.test.js.snap b/test/parser/statement/__snapshots__/Set.test.js.snap index 984794140..0204e40a6 100644 --- a/test/parser/statement/__snapshots__/Set.test.js.snap +++ b/test/parser/statement/__snapshots__/Set.test.js.snap @@ -114,6 +114,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "IndexedGet", }, "right": Literal { @@ -460,6 +461,7 @@ Array [ }, "type": "Variable", }, + "optional": false, "type": "DottedGet", }, "right": Literal {