Repository: sjbarag/brs
Pull request #593 (2020-12-10 00:53:24)

feat(interp,exts): implement stack trace
# Summary

This implements an extension `_brs_.getStackTrace`, which, as you might expect, gets the stack trace.

Also, I cleaned up the `README` a little -- reorganized the extensions alphabetically.

lkipke: > Great work @lkipke ! This is a very clean implementation, and is well documented as well. Now that component libraries landed (#590), would you mind checking `_brs_.getStackTrace()` from inside one of those? Everything should be fine, but it seems worth checking before this merges 😃

Yep, it all looks good!
```
      Array [
        "[Foo:Bar::init]",
    +   "<Component: roArray> =
    + [
    +     /Users/levi.kipke/Projects/brs/test/e2e/resources/component-library/source/main.brs:2:4
    +     /var/folders/sr/j_zsg28s4tqgc3g0kw74hs1rbz38fw/T/brs-pkgcomponentslibrary.zip2bz65m/components/Bar.brs:3:4
    +     /var/folders/sr/j_zsg28s4tqgc3g0kw74hs1rbz38fw/T/brs-pkgcomponentslibrary.zip2bz65m/components/Bar.brs:3:10
    +     /var/folders/sr/j_zsg28s4tqgc3g0kw74hs1rbz38fw/T/brs-pkgcomponentslibrary.zip2bz65m/source/util.brs:2:4
    +     /var/folders/sr/j_zsg28s4tqgc3g0kw74hs1rbz38fw/T/brs-pkgcomponentslibrary.zip2bz65m/source/util.brs:2:10
    + ]",
        "[Foo:util::someUtil]",
```

Patch:
diff --git a/README.md b/README.md
index 2a89041..4a34395 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,30 @@ $ yarn run-s clean build test
 
 For the most part, `brs` attempts to emulate BrightScript as closely as possible. However, in the spirit of unit testing, it also has a few extensions that will help with testing.
 
+### `_brs_.getStackTrace(numEntries = 10, excludePatterns = [])`
+
+Prints out the stack trace. You can configure how many lines to print, and also use the `excludePatterns` arg, which is an **array of strings**, to exclude files/folders from being included in the trace. *Note: this function internally uses the [Javascript regex engine](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp) to match the strings, not [Brightscript PCRE](https://developer.roku.com/en-ca/docs/references/brightscript/components/roregex.md).*
+
+```brs
+print _brs_.getStackTrace()
+' [
+'     "test/baz.test.brs:1:10",
+'     "lib/utils.brs:25:4",
+'     "foo/baz.brs:50:29"
+' ]
+
+print _brs_.getStackTrace(1)
+' [
+'     "test/baz.test.brs:1:10",
+' ]
+
+print _brs_.getStackTrace(10, ["lib"])
+' [
+'     "test/baz.test.brs:1:10",
+'     "foo/baz.brs:50:29"
+' ]
+```
+
 ### `_brs_.process`
 
 Allows you to access the command line arguments and locale. Locale changes will be reflected in related `RoDeviceInfo` functions and the standard library `Tr` function. Usage:
diff --git a/src/extensions/GetStackTrace.ts b/src/extensions/GetStackTrace.ts
new file mode 100644
index 0000000..1eab2c0
--- /dev/null
+++ b/src/extensions/GetStackTrace.ts
@@ -0,0 +1,67 @@
+import {
+    BrsString,
+    RoArray,
+    Callable,
+    ValueKind,
+    StdlibArgument,
+    Int32,
+    BrsInvalid,
+    BrsType,
+    isBrsString,
+} from "../brsTypes";
+import { Location } from "../lexer";
+import { Interpreter } from "../interpreter";
+
+const INTERNAL_REGEX_FILTER = /\(internal\)/;
+
+/**
+ * Returns a stack trace in the format:
+ * [
+ *   filename:line:column,
+ *   ...
+ * ]
+ */
+export const GetStackTrace = new Callable("GetStackTrace", {
+    signature: {
+        args: [
+            new StdlibArgument("numEntries", ValueKind.Int32, new Int32(10)),
+            new StdlibArgument("excludePatterns", ValueKind.Dynamic, BrsInvalid.Instance),
+        ],
+        returns: ValueKind.Object,
+    },
+    impl: (interpreter: Interpreter, numEntries: Int32, excludePatterns: BrsType) => {
+        let stack = interpreter.stack;
+
+        // Filter out any files that the consumer doesn't want to include
+        if (excludePatterns instanceof RoArray) {
+            excludePatterns.getValue().forEach((pattern) => {
+                if (isBrsString(pattern)) {
+                    let regexFilter = new RegExp(pattern.value);
+                    stack = stack.filter((location) => !regexFilter.test(location.file));
+                }
+            });
+        } else if (!(excludePatterns instanceof BrsInvalid)) {
+            interpreter.stderr.write("Patterns to exclude argument must be an roArray");
+        }
+
+        return new RoArray(
+            stack
+                // Filter out any internal stack traces.
+                .filter((location) => !INTERNAL_REGEX_FILTER.test(location.file))
+                // Remove any duplicate entries that appear next to each other in the stack.
+                .filter((location, index, locations) => {
+                    if (index === 0) return true;
+                    let prevLocation = locations[index - 1];
+                    return !Location.equalTo(location, prevLocation);
+                })
+                .map(
+                    (location) =>
+                        new BrsString(
+                            `${location.file}:${location.start.line}:${location.start.column}`
+                        )
+                )
+                // Get the last item on the stack
+                .slice(-1 * numEntries.getValue())
+        );
+    },
+});
diff --git a/src/extensions/index.ts b/src/extensions/index.ts
index b422934..97367b9 100644
--- a/src/extensions/index.ts
+++ b/src/extensions/index.ts
@@ -12,6 +12,7 @@ import {
     resetMockFunction,
 } from "./resetMocks";
 import { triggerKeyEvent } from "./triggerKeyEvent";
+import { GetStackTrace } from "./GetStackTrace";
 
 export const _brs_ = new RoAssociativeArray([
     { name: new BrsString("mockComponent"), value: mockComponent },
@@ -26,4 +27,5 @@ export const _brs_ = new RoAssociativeArray([
     { name: new BrsString("process"), value: Process },
     { name: new BrsString("global"), value: mGlobal },
     { name: new BrsString("triggerKeyEvent"), value: triggerKeyEvent },
+    { name: new BrsString("getStackTrace"), value: GetStackTrace },
 ]);
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index 0a09a89..8223714 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -78,6 +78,7 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
     readonly stderr: OutputProxy;
     readonly temporaryVolume: MemoryFileSystem = new MemoryFileSystem();
 
+    stack: Location[] = [];
     location: Location;
 
     /** Allows consumers to observe errors as they're detected. */
@@ -1632,14 +1633,32 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
 
     evaluate(this: Interpreter, expression: Expr.Expression): BrsType {
         this.location = expression.location;
+        this.stack.push(this.location);
         this.reportCoverageHit(expression);
-        return expression.accept<BrsType>(this);
+
+        let value;
+        try {
+            value = expression.accept<BrsType>(this);
+        } finally {
+            this.stack.pop();
+        }
+
+        return value;
     }
 
     execute(this: Interpreter, statement: Stmt.Statement): BrsType {
         this.location = statement.location;
+        this.stack.push(this.location);
         this.reportCoverageHit(statement);
-        return statement.accept<BrsType>(this);
+
+        let value;
+        try {
+            value = statement.accept<BrsType>(this);
+        } finally {
+            this.stack.pop();
+        }
+
+        return value;
     }
 
     /**
diff --git a/src/lexer/Token.ts b/src/lexer/Token.ts
index f9993f9..cb19ed6 100644
--- a/src/lexer/Token.ts
+++ b/src/lexer/Token.ts
@@ -37,6 +37,18 @@ export interface Location {
     file: string;
 }
 
+export namespace Location {
+    export function equalTo(loc1: Location, loc2: Location) {
+        return (
+            loc1.file === loc2.file &&
+            loc1.start.line === loc2.start.line &&
+            loc1.start.column === loc2.start.column &&
+            loc1.end.line === loc2.end.line &&
+            loc1.end.column === loc2.end.column
+        );
+    }
+}
+
 /** A line-column pair. */
 type LineAndColumn = {
     /** A *one-indexed* line number. */


Test patch:
diff --git a/test/e2e/GetStackTrace.test.js b/test/e2e/GetStackTrace.test.js
new file mode 100644
index 0000000..fbd56b3
--- /dev/null
+++ b/test/e2e/GetStackTrace.test.js
@@ -0,0 +1,45 @@
+const { execute } = require("../../lib");
+const { createMockStreams, resourceFile, allArgs } = require("./E2ETests");
+
+describe("GetStackTrace", () => {
+    let outputStreams;
+
+    beforeAll(() => {
+        outputStreams = createMockStreams();
+        outputStreams.root = __dirname + "/resources";
+    });
+
+    test("components/stack-trace/main.brs", async () => {
+        await execute(
+            [
+                resourceFile("components", "stack-trace", "@external", "package", "utils.brs"),
+                resourceFile("components", "stack-trace", "print.brs"),
+                resourceFile("components", "stack-trace", "main.brs"),
+            ],
+            outputStreams
+        );
+
+        expect(
+            allArgs(outputStreams.stdout.write)
+                .filter((arg) => arg !== "\n")
+                .map((line) => line.replace(process.cwd(), ""))
+        ).toEqual([
+            "--- stack ---",
+            "/test/e2e/resources/components/stack-trace/main.brs:2:4",
+            "/test/e2e/resources/components/stack-trace/@external/package/utils.brs:2:4",
+            "/test/e2e/resources/components/stack-trace/main.brs:6:4",
+            "/test/e2e/resources/components/stack-trace/print.brs:2:4",
+            "/test/e2e/resources/components/stack-trace/print.brs:2:12",
+            "--- stack ---",
+            "/test/e2e/resources/components/stack-trace/print.brs:4:12",
+            "--- stack ---",
+            "/test/e2e/resources/components/stack-trace/main.brs:2:4",
+            "/test/e2e/resources/components/stack-trace/main.brs:6:4",
+            "/test/e2e/resources/components/stack-trace/print.brs:6:4",
+            "/test/e2e/resources/components/stack-trace/print.brs:6:12",
+            "--- stack ---",
+            "/test/e2e/resources/components/stack-trace/main.brs:2:4",
+            "/test/e2e/resources/components/stack-trace/main.brs:6:4",
+        ]);
+    });
+});
diff --git a/test/e2e/resources/components/stack-trace/@external/package/utils.brs b/test/e2e/resources/components/stack-trace/@external/package/utils.brs
new file mode 100644
index 0000000..9bd9d61
--- /dev/null
+++ b/test/e2e/resources/components/stack-trace/@external/package/utils.brs
@@ -0,0 +1,3 @@
+function external()
+    foo()
+end function
diff --git a/test/e2e/resources/components/stack-trace/main.brs b/test/e2e/resources/components/stack-trace/main.brs
new file mode 100644
index 0000000..2468358
--- /dev/null
+++ b/test/e2e/resources/components/stack-trace/main.brs
@@ -0,0 +1,7 @@
+sub main()
+    external()
+end sub
+
+function foo()
+    printStackTrace()
+end function
diff --git a/test/e2e/resources/components/stack-trace/print.brs b/test/e2e/resources/components/stack-trace/print.brs
new file mode 100644
index 0000000..6599c3f
--- /dev/null
+++ b/test/e2e/resources/components/stack-trace/print.brs
@@ -0,0 +1,18 @@
+function printStackTrace()
+    stack = _brs_.getStackTrace()
+    stackTraceHelper(stack)
+    stack = _brs_.getStackTrace(1)
+    stackTraceHelper(stack)
+    stack = _brs_.getStackTrace(10, ["@external"])
+    stackTraceHelper(stack)
+    stack = _brs_.getStackTrace(10, ["print.brs", "@external"])
+    stackTraceHelper(stack)
+end function
+
+' Helper function for cleaner output in test case.
+function stackTraceHelper(stack)
+    print "--- stack ---"
+    for each line in stack
+        print line
+    end for
+end function
diff --git a/test/extensions/getStackTrace.test.js b/test/extensions/getStackTrace.test.js
new file mode 100644
index 0000000..18066fb
--- /dev/null
+++ b/test/extensions/getStackTrace.test.js
@@ -0,0 +1,73 @@
+const brs = require("brs");
+const { Int32 } = require("../../lib/brsTypes/Int32");
+const { Callable, BrsString, BrsInvalid, RoArray, RoAssociativeArray, ValueKind } = brs.types;
+const { GetStackTrace } = require("../../lib/extensions/GetStackTrace");
+const { Interpreter } = require("../../lib/interpreter");
+
+describe("GetStackTrace", () => {
+    let interpreter;
+
+    beforeEach(() => {
+        interpreter = new Interpreter();
+        interpreter.stack = [
+            {
+                file: "a/b/c.brs",
+                start: {
+                    line: 5,
+                    column: 6,
+                },
+            },
+            {
+                file: "@external/package/utils.brs",
+                start: {
+                    line: 3,
+                    column: 4,
+                },
+            },
+            {
+                file: "test/a/b/c.test.brs",
+                start: {
+                    line: 1,
+                    column: 2,
+                },
+            },
+        ];
+    });
+
+    it("returns a correctly formatted stack trace", () => {
+        let result = GetStackTrace.call(interpreter);
+        expect(result).toBeInstanceOf(RoArray);
+        expect(result.getValue().map((line) => line.value)).toEqual([
+            "a/b/c.brs:5:6",
+            "@external/package/utils.brs:3:4",
+            "test/a/b/c.test.brs:1:2",
+        ]);
+    });
+
+    it("returns the correct number of lines when specified", () => {
+        let result = GetStackTrace.call(interpreter, new Int32(1));
+        expect(result.getValue().map((line) => line.value)).toEqual(["test/a/b/c.test.brs:1:2"]);
+    });
+
+    it("filters lines correctly", () => {
+        let result = GetStackTrace.call(
+            interpreter,
+            new Int32(10),
+            new RoArray([new BrsString("@external/package")])
+        );
+        expect(result.getValue().map((line) => line.value)).toEqual([
+            "a/b/c.brs:5:6",
+            "test/a/b/c.test.brs:1:2",
+        ]);
+
+        result = GetStackTrace.call(
+            interpreter,
+            new Int32(10),
+            new RoArray([new BrsString("package")])
+        );
+        expect(result.getValue().map((line) => line.value)).toEqual([
+            "a/b/c.brs:5:6",
+            "test/a/b/c.test.brs:1:2",
+        ]);
+    });
+});