Skip to content

Commit

Permalink
feat(lex,parse): Add stub try/catch implementation (#34)
Browse files Browse the repository at this point in the history
* feat(lex): Emit try/catch/throw lexemes for parsing

RBI 9.4 adds support for error handling via the `try`/`catch` model [1].
Interestingly, `try`, `catch`, and `endtry` are all valid identifiers!
That makes parsing a little bit tougher, but it's not unprecedented in
this language.  Detect `try`/`catch`/throw`/`end try` during lexing and
emit the proper lexemes.

[1] https://developer.roku.com/docs/references/brightscript/language/error-handling.html
see sjbarag#554

* feat(parse): Consume try, catch, and end try lexemes

Throwing exceptions and catching them aren't yet supported in `brs`, so
executing only the `try` block seems to be a reasonable "bare minimum"
approach to try/catch support.  Handle `try`, `catch`, and `end try`
lexemes in the parser, emitting TryCatch statements for the interpreter
to execute naïvely.

see sjbarag#554

* Fixed Test Case

---------

Co-authored-by: Sean Barag <sean.barag@hulu.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>
  • Loading branch information
3 people committed Dec 1, 2023
1 parent 6e5b422 commit b8e459a
Show file tree
Hide file tree
Showing 12 changed files with 1,283 additions and 7 deletions.
5 changes: 5 additions & 0 deletions src/coverage/FileCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ export class FileCoverage implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType
return BrsInvalid.Instance;
}

visitTryCatch(statement: Stmt.TryCatch) {
// TODO: implement statement/expression coverage for try/catch
return BrsInvalid.Instance;
}

visitFor(statement: Stmt.For) {
this.execute(statement.counterDeclaration);
this.evaluate(statement.counterDeclaration.value);
Expand Down
5 changes: 5 additions & 0 deletions src/interpreter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,11 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
}
}

visitTryCatch(statement: Stmt.TryCatch): BrsInvalid {
this.visitBlock(statement.tryBlock);
return BrsInvalid.Instance;
}

visitBlock(block: Stmt.Block): BrsType {
block.statements.forEach((statement) => this.execute(statement));
return BrsInvalid.Instance;
Expand Down
4 changes: 4 additions & 0 deletions src/lexer/Lexeme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export enum Lexeme {
// canonical source: https://sdkdocs.roku.com/display/sdkdoc/Reserved+Words
And = "And",
Box = "Box",
Catch = "Catch",
CreateObject = "CreateObject",
Dim = "Dim",
Else = "Else",
Expand All @@ -77,6 +78,7 @@ export enum Lexeme {
EndFor = "EndFor",
EndIf = "EndIf",
EndSub = "EndSub",
EndTry = "EndTry",
EndWhile = "EndWhile",
Eval = "Eval",
Exit = "Exit",
Expand Down Expand Up @@ -105,8 +107,10 @@ export enum Lexeme {
Stop = "Stop",
Sub = "Sub",
Tab = "Tab",
Throw = "Throw",
To = "To",
True = "True",
Try = "Try",
Type = "Type",
While = "While",

Expand Down
10 changes: 8 additions & 2 deletions src/lexer/ReservedWords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Lexeme as L } from "./Lexeme";

/**
* The set of all reserved words in the reference BrightScript runtime. These can't be used for any
* other purpose within a BrightScript file.
* other purpose (e.g. as identifiers) within a BrightScript file.
* @see https://sdkdocs.roku.com/display/sdkdoc/Reserved+Words
*/
export const ReservedWords = new Set([
Expand Down Expand Up @@ -44,19 +44,21 @@ export const ReservedWords = new Set([
"tab",
"then",
"to",
"throw",
"true",
"type",
"while",
]);

/**
* The set of keywords in the reference BrightScript runtime. Any of these that *are not* reserved
* words can be used within a BrightScript file for other purposes, e.g. `tab`.
* words can be used within a BrightScript file for other purposes as identifiers, e.g. `tab`.
*
* Unfortunately there's no canonical source for this!
*/
export const KeyWords: { [key: string]: L } = {
and: L.And,
catch: L.Catch,
dim: L.Dim,
else: L.Else,
elseif: L.ElseIf,
Expand All @@ -69,6 +71,8 @@ export const KeyWords: { [key: string]: L } = {
"end if": L.EndIf,
endsub: L.EndSub,
"end sub": L.EndSub,
endtry: L.EndTry,
"end try": L.EndTry, // note: 'endtry' (no space) is *not* a keyword
endwhile: L.EndWhile,
"end while": L.EndWhile,
exit: L.Exit,
Expand All @@ -94,6 +98,8 @@ export const KeyWords: { [key: string]: L } = {
stop: L.Stop,
sub: L.Sub,
to: L.To,
try: L.Try,
throw: L.Throw,
true: L.True,
while: L.While,
};
42 changes: 40 additions & 2 deletions src/parser/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ type BlockTerminator =
| Lexeme.EndSub
| Lexeme.EndFunction
| Lexeme.Newline // possible only in a single-line `if` statement
| Lexeme.Eof; // possible only in a single-line `if` statement
| Lexeme.Eof // possible only in a single-line `if` statement
| Lexeme.Catch
| Lexeme.EndTry;

/** The set of operators valid for use in assignment statements. */
const assignmentOperators = [
Expand Down Expand Up @@ -90,7 +92,13 @@ const allowedProperties = [
];

/** List of Lexeme that are allowed as local var identifiers. */
const allowedIdentifiers = [Lexeme.EndFor, Lexeme.ExitFor, Lexeme.ForEach];
const allowedIdentifiers = [
Lexeme.EndFor,
Lexeme.ExitFor,
Lexeme.ForEach,
Lexeme.Try,
Lexeme.Catch,
];

/**
* List of string versions of Lexeme that are NOT allowed as local var identifiers.
Expand Down Expand Up @@ -576,6 +584,10 @@ export class Parser {
return stopStatement();
}

if (check(Lexeme.Try)) {
return tryCatch();
}

if (check(Lexeme.If)) {
return ifStatement();
}
Expand Down Expand Up @@ -629,6 +641,32 @@ export class Parser {
return setStatement(...additionalterminators);
}

function tryCatch(): Stmt.TryCatch {
let tryKeyword = advance();
let tryBlock = block(Lexeme.Catch);
if (!tryBlock) {
throw addError(peek(), "Expected 'catch' to terminate try block");
}

if (!check(Lexeme.Identifier)) {
// defer this error so we can parse the `catch` block.
// it'll be thrown if the catch block parses successfully otherwise.
throw addError(peek(), "Expected variable name for caught error after 'catch'");
}

let caughtVariable = new Expr.Variable(advance() as Identifier);
let catchBlock = block(Lexeme.EndTry);
if (!catchBlock) {
throw addError(peek(), "Expected 'end try' or 'endtry' to terminate catch block");
}

return new Stmt.TryCatch(tryBlock.body, catchBlock.body, caughtVariable, {
try: tryKeyword,
catch: tryBlock.closingToken,
endtry: catchBlock.closingToken,
});
}

function whileStatement(): Stmt.While {
const whileKeyword = advance();
const condition = expression();
Expand Down
28 changes: 28 additions & 0 deletions src/parser/Statement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Visitor<T> {
visitIndexedSet(statement: IndexedSet): BrsType;
visitIncrement(expression: Increment): BrsInvalid;
visitLibrary(statement: Library): BrsInvalid;
visitTryCatch(statement: TryCatch): BrsInvalid;
}

let statementTypes = new Set<string>([
Expand Down Expand Up @@ -575,3 +576,30 @@ export class Library extends AstNode implements Statement {
};
}
}

export class TryCatch extends AstNode implements Statement {
constructor(
readonly tryBlock: Block,
readonly catchBlock: Block,
readonly errorBinding: Expr.Variable,
readonly tokens: {
try: Token;
catch: Token;
endtry: Token;
}
) {
super("TryCatch");
}

accept<R>(visitor: Visitor<R>): BrsType {
return visitor.visitTryCatch(this);
}

get location() {
return {
file: this.tokens.try.location.file,
start: this.tokens.endtry.location.start,
end: this.tokens.endtry.location.end,
};
}
}
11 changes: 11 additions & 0 deletions test/e2e/Syntax.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,15 @@ describe("end to end syntax", () => {
"optional chaining works",
]);
});
test("try-catch.brs", async () => {
await execute([resourceFile("try-catch.brs")], outputStreams);
expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
"[pre_try] a = ",
"5",
"[in_try] a = ",
"10",
"[post_try] a = ",
"10",
]);
});
});
13 changes: 13 additions & 0 deletions test/e2e/resources/try-catch.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
sub main()
a = 5

print "[pre_try] a = " a
try
a = a * 2
print "[in_try] a = " a
catch e
' currently unimplemented
end try

print "[post_try] a = " a
end sub
22 changes: 19 additions & 3 deletions test/lexer/Lexer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,8 @@ describe("lexer", () => {
describe("identifiers", () => {
it("matches single-word keywords", () => {
// test just a sample of single-word reserved words for now.
// if we find any that we've missed
let { tokens } = Lexer.scan("and or if else endif return true false line_num");
// if we find any that we've missed, add them here
let { tokens } = Lexer.scan("and or if else endif return true false line_num throw");
expect(tokens.map((w) => w.kind)).toEqual([
Lexeme.And,
Lexeme.Or,
Expand All @@ -403,20 +403,24 @@ describe("lexer", () => {
Lexeme.True,
Lexeme.False,
Lexeme.Identifier,
Lexeme.Throw,
Lexeme.Eof,
]);
expect(tokens.filter((w) => !!w.literal).length).toBe(0);
});

it("matches multi-word keywords", () => {
let { tokens } = Lexer.scan("else if end if end while End Sub end Function Exit wHILe");
let { tokens } = Lexer.scan(
"else if end if end while End Sub end Function Exit wHILe end try"
);
expect(tokens.map((w) => w.kind)).toEqual([
Lexeme.ElseIf,
Lexeme.EndIf,
Lexeme.EndWhile,
Lexeme.EndSub,
Lexeme.EndFunction,
Lexeme.ExitWhile,
Lexeme.EndTry,
Lexeme.Eof,
]);
expect(tokens.filter((w) => !!w.literal).length).toBe(0);
Expand All @@ -431,6 +435,18 @@ describe("lexer", () => {
]);
});

it("reads try/catch/throw properly", () => {
let { tokens } = Lexer.scan("try catch throw end try endtry");
expect(tokens.map((w) => w.kind)).toEqual([
Lexeme.Try,
Lexeme.Catch,
Lexeme.Throw,
Lexeme.EndTry,
Lexeme.EndTry,
Lexeme.Eof,
]);
});

it("matches keywords with silly capitalization", () => {
let { tokens } = Lexer.scan("iF ELSE eNDIf FUncTioN");
expect(tokens.map((w) => w.kind)).toEqual([
Expand Down
17 changes: 17 additions & 0 deletions test/parser/ParserTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,20 @@ exports.locationEqual = function (loc1, loc2) {
loc1.end.column === loc2.end.column
);
};

/**
* Removes least-common leading indentation from a string, effectively "unindenting" a multi-line
* template string.
* @param {string} str - the string to unindent
* @return {string} `str`, but reformatted so that at least one line starts at column 0
*/
exports.deindent = function deindent(str) {
let lines = str.split("\n");
let firstNonEmptyLine = lines.find((line) => line.trim() !== "");
if (firstNonEmptyLine == null) {
return str;
}

let baseIndent = firstNonEmptyLine.length - firstNonEmptyLine.trim().length;
return lines.map((line) => line.substring(baseIndent)).join("\n");
};

0 comments on commit b8e459a

Please sign in to comment.