Repository: sjbarag/brs
Pull request #256 (2019-06-10 19:23:49)

feat(stdlib,e2e): Implement global `Run` function
RBI's standard library includes a `Run` function, used to allow a BrightScript file to execute another BrightScript file given its full file path (using a `main` function as its entry point).  That's real handy, as it'll allow several files with `main` functions to co-exist, as long as they're not all in the `source/` directory.


Patch:
diff --git a/src/brsTypes/Callable.ts b/src/brsTypes/Callable.ts
index c8a102d..d01f059 100644
--- a/src/brsTypes/Callable.ts
+++ b/src/brsTypes/Callable.ts
@@ -68,7 +68,7 @@ export interface Signature {
 }
 
 /** A BrightScript function signature paired with its implementation. */
-type SignatureAndImplementation = {
+export type SignatureAndImplementation = {
     /** A BrightScript function's signature. */
     signature: Signature;
     /** The implementation corresponding to `signature`. */
diff --git a/src/index.ts b/src/index.ts
index 524bcdc..cd7e716 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -96,6 +96,40 @@ export async function execute(filenames: string[], options: Partial<ExecutionOpt
     return interpreter.exec(statements);
 }
 
+/**
+ * A synchronous version of `execute`. Executes a BrightScript file by path and writes its output to the streams
+ * provided in `options`.
+ *
+ * @param filename the paths to BrightScript files to execute synchronously
+ * @param options configuration for the execution, including the streams to use for `stdout` and
+ *                `stderr` and the base directory for path resolution
+ * @param args the set of arguments to pass to the `main` function declared in one of the provided filenames
+ *
+ * @returns the value returned by the executed file(s)
+ */
+export function executeSync(
+    filenames: string[],
+    options: Partial<ExecutionOptions>,
+    args: BrsTypes.BrsType[]
+) {
+    const executionOptions = Object.assign(defaultExecutionOptions, options);
+    const interpreter = new Interpreter(executionOptions); // shared between files
+
+    let manifest = PP.getManifestSync(executionOptions.root);
+
+    let allStatements = filenames
+        .map(filename => {
+            let contents = fs.readFileSync(filename, "utf8");
+            let scanResults = Lexer.scan(contents, filename);
+            let preprocessor = new PP.Preprocessor();
+            let preprocessorResults = preprocessor.preprocess(scanResults.tokens, manifest);
+            return Parser.parse(preprocessorResults.processedTokens).statements;
+        })
+        .reduce((allStatements, statements) => [...allStatements, ...statements], []);
+
+    return interpreter.exec(allStatements, ...args);
+}
+
 /**
  * Launches an interactive read-execute-print loop, which reads input from
  * `stdin` and executes it.
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index 66ad4a1..bcff216 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -56,6 +56,7 @@ export const defaultExecutionOptions: ExecutionOptions = {
 export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType> {
     private _environment = new Environment();
 
+    readonly options: ExecutionOptions;
     readonly stdout: OutputProxy;
     readonly stderr: OutputProxy;
     readonly temporaryVolume: MemoryFileSystem = new MemoryFileSystem();
@@ -94,11 +95,13 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
 
     /**
      * Creates a new Interpreter, including any global properties and functions.
-     * @param outputStreams the WriteStreams to use for `stdout` and `stderr`.
+     * @param options configuration for the execution, including the streams to use for `stdout` and
+     *                `stderr` and the base directory for path resolution
      */
-    constructor(outputStreams: ExecutionOptions = defaultExecutionOptions) {
-        this.stdout = new OutputProxy(outputStreams.stdout);
-        this.stderr = new OutputProxy(outputStreams.stderr);
+    constructor(options: ExecutionOptions = defaultExecutionOptions) {
+        this.stdout = new OutputProxy(options.stdout);
+        this.stderr = new OutputProxy(options.stderr);
+        this.options = options;
 
         Object.keys(StdLib)
             .map(name => (StdLib as any)[name])
@@ -137,10 +140,10 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
         }
     }
 
-    exec(statements: ReadonlyArray<Stmt.Statement>) {
+    exec(statements: ReadonlyArray<Stmt.Statement>, ...args: BrsType[]) {
         let results = statements.map(statement => this.execute(statement));
         try {
-            let maybeMain = this._environment.get({
+            let mainVariable = new Expr.Variable({
                 kind: Lexeme.Identifier,
                 text: "main",
                 isReserved: false,
@@ -156,11 +159,26 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
                     file: "(internal)",
                 },
             });
+
+            let maybeMain = this.visitVariable(mainVariable);
+
             if (maybeMain.kind === ValueKind.Callable) {
-                results = [maybeMain.call(this)];
+                results = [
+                    this.visitCall(
+                        new Expr.Call(
+                            mainVariable,
+                            mainVariable.name,
+                            args.map(arg => new Expr.Literal(arg, mainVariable.location))
+                        )
+                    ),
+                ];
             }
         } catch (err) {
-            throw err;
+            if (err instanceof Stmt.ReturnValue) {
+                results = [err.value || BrsInvalid.Instance];
+            } else {
+                throw err;
+            }
         } finally {
             return results;
         }
diff --git a/src/lexer/ReservedWords.ts b/src/lexer/ReservedWords.ts
index ddbaa5a..c4d9d9f 100644
--- a/src/lexer/ReservedWords.ts
+++ b/src/lexer/ReservedWords.ts
@@ -50,7 +50,7 @@ export const ReservedWords = new Set([
 ]);
 
 /**
- * The set of keywords in the refrence BrightScript runtime. Any of these that *are not* reserved
+ * 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`.
  *
  * Unfortunately there's no canonical source for this!
diff --git a/src/preprocessor/Manifest.ts b/src/preprocessor/Manifest.ts
index 595075c..e076f04 100644
--- a/src/preprocessor/Manifest.ts
+++ b/src/preprocessor/Manifest.ts
@@ -30,6 +30,23 @@ export async function getManifest(rootDir: string): Promise<Manifest> {
     return parseManifest(contents);
 }
 
+/**
+ * A synchronous version of `getManifest`.
+ * @param rootDir the root directory in which a `manifest` file is expected
+ * @returns a map of string to JavaScript number, string, or boolean, representing the manifest
+ *          file's contents
+ */
+export function getManifestSync(rootDir: string): Manifest {
+    let manifestPath = path.join(rootDir, "manifest");
+
+    if (!fs.existsSync(manifestPath)) {
+        return new Map();
+    }
+
+    let contents = fs.readFileSync(manifestPath, "utf-8");
+    return parseManifest(contents);
+}
+
 /**
  * Attempts to parse a `manifest` file's contents into a map of string to JavaScript
  * number, string, or boolean.
diff --git a/src/preprocessor/index.ts b/src/preprocessor/index.ts
index 0226b0a..bca132c 100644
--- a/src/preprocessor/index.ts
+++ b/src/preprocessor/index.ts
@@ -64,4 +64,4 @@ import { BrsError } from "../Error";
 import { ParseError } from "../parser";
 export { Chunk };
 export { Parser } from "./Parser";
-export { getManifest, getBsConst, Manifest } from "./Manifest";
+export { getManifest, getManifestSync, getBsConst, Manifest } from "./Manifest";
diff --git a/src/stdlib/File.ts b/src/stdlib/File.ts
index 7f96347..e3858e3 100644
--- a/src/stdlib/File.ts
+++ b/src/stdlib/File.ts
@@ -2,17 +2,25 @@ import { Callable, ValueKind, BrsString, BrsBoolean, RoArray, StdlibArgument } f
 import { Interpreter } from "../interpreter";
 import { URL } from "url";
 import MemoryFileSystem from "memory-fs";
+import * as fs from "fs";
+
+type Volume = MemoryFileSystem | typeof fs;
 
 /*
  * Returns a memfs volume based on the brs path uri.  For example, passing in
- * "tmp:///test.txt" will return the memfs temporary volume on the interpreter.
+ * "tmp:/test.txt" will return the memfs temporary volume on the interpreter.
  *
  * Returns invalid in no appopriate volume is found for the path
  */
-export function getVolumeByPath(interpreter: Interpreter, path: string): MemoryFileSystem | null {
+export function getVolumeByPath(interpreter: Interpreter, path: string): Volume | null {
     try {
         const protocol = new URL(path).protocol;
-        if (protocol === "tmp:") return interpreter.temporaryVolume;
+        if (protocol === "tmp:") {
+            return interpreter.temporaryVolume;
+        }
+        if (protocol === "pkg:") {
+            return fs;
+        }
     } catch (err) {
         return null;
     }
@@ -21,9 +29,9 @@ export function getVolumeByPath(interpreter: Interpreter, path: string): MemoryF
 
 /*
  * Returns a memfs file path from a brs file uri
- *   ex. "tmp:///test/test1.txt" -> "/test/test1.txt"
+ *   ex. "tmp:/test/test1.txt" -> "/test/test1.txt"
  */
-export function getMemfsPath(fileUri: string) {
+export function getPath(fileUri: string) {
     return new URL(fileUri).pathname;
 }
 
@@ -46,8 +54,8 @@ export const CopyFile = new Callable("CopyFile", {
             return BrsBoolean.False;
         }
 
-        const srcMemfsPath = getMemfsPath(src.value);
-        const dstMemfsPath = getMemfsPath(dst.value);
+        const srcMemfsPath = getPath(src.value);
+        const dstMemfsPath = getPath(dst.value);
         try {
             let contents = srcVolume.readFileSync(srcMemfsPath);
             dstVolume.writeFileSync(dstMemfsPath, contents);
@@ -77,8 +85,8 @@ export const MoveFile = new Callable("MoveFile", {
             return BrsBoolean.False;
         }
 
-        const srcMemfsPath = getMemfsPath(src.value);
-        const dstMemfsPath = getMemfsPath(dst.value);
+        const srcMemfsPath = getPath(src.value);
+        const dstMemfsPath = getPath(dst.value);
         try {
             let contents = srcVolume.readFileSync(srcMemfsPath);
             dstVolume.writeFileSync(dstMemfsPath, contents);
@@ -102,7 +110,7 @@ export const DeleteFile = new Callable("DeleteFile", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getMemfsPath(file.value);
+        const memfsPath = getPath(file.value);
         try {
             volume.unlinkSync(memfsPath);
             return BrsBoolean.True;
@@ -124,7 +132,7 @@ export const DeleteDirectory = new Callable("DeleteDirectory", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getMemfsPath(dir.value);
+        const memfsPath = getPath(dir.value);
         try {
             volume.rmdirSync(memfsPath);
             return BrsBoolean.True;
@@ -146,7 +154,7 @@ export const CreateDirectory = new Callable("CreateDirectory", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getMemfsPath(dir.value);
+        const memfsPath = getPath(dir.value);
         try {
             volume.mkdirSync(memfsPath);
             return BrsBoolean.True;
@@ -185,7 +193,7 @@ export const ListDir = new Callable("ListDir", {
             return new RoArray([]);
         }
 
-        const memfsPath = getMemfsPath(path.value);
+        const memfsPath = getPath(path.value);
         try {
             let subPaths = volume.readdirSync(memfsPath).map(s => new BrsString(s));
             return new RoArray(subPaths);
@@ -207,7 +215,7 @@ export const ReadAsciiFile = new Callable("ReadAsciiFile", {
             return new BrsString("");
         }
 
-        const memfsPath = getMemfsPath(filepath.value);
+        const memfsPath = getPath(filepath.value);
         return new BrsString(volume.readFileSync(memfsPath).toString());
     },
 });
@@ -227,7 +235,7 @@ export const WriteAsciiFile = new Callable("WriteAsciiFile", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getMemfsPath(filepath.value);
+        const memfsPath = getPath(filepath.value);
         volume.writeFileSync(memfsPath, text.value);
         return BrsBoolean.True;
     },
diff --git a/src/stdlib/Run.ts b/src/stdlib/Run.ts
new file mode 100644
index 0000000..11ed5ff
--- /dev/null
+++ b/src/stdlib/Run.ts
@@ -0,0 +1,108 @@
+import * as path from "path";
+
+import * as brs from "../";
+import {
+    BrsType,
+    ValueKind,
+    Callable,
+    BrsString,
+    BrsInvalid,
+    StdlibArgument,
+    SignatureAndImplementation,
+    RoArray,
+    isBrsString,
+} from "../brsTypes";
+import { Interpreter } from "../interpreter";
+import { getVolumeByPath, getPath } from "./File";
+import { BrsComponent } from "../brsTypes/components/BrsComponent";
+
+/**
+ * Runs a file (or set of files) with the provided arguments, returning either the value returned by those files'
+ * `main` function or `invalid` if an error occurs.
+ *
+ * @param interpreter the interpreter hosting this call to `Run`
+ * @param filenames a list of files to lex, parse, and run
+ * @param args the arguments to pass into the found `main` function
+ *
+ * @returns the value returned by the executed file(s) if no errors are detected, otherwise `invalid`
+ */
+function runFiles(interpreter: Interpreter, filenames: BrsString[], args: BrsType[]) {
+    let volumes = filenames.map(filename => getVolumeByPath(interpreter, filename.value));
+    let pathsToFiles = filenames.map(filename =>
+        path.join(interpreter.options.root, getPath(filename.value))
+    );
+
+    // if the file-to-run doesn't exist, RBI returns invalid
+    if (!volumes.every(volume => volume != null)) {
+        return BrsInvalid.Instance;
+    }
+
+    try {
+        let results = brs.executeSync(pathsToFiles, interpreter.options, args);
+        return results[0] || BrsInvalid.Instance;
+    } catch (err) {
+        // swallow errors and just return invalid; RBI returns invalid for "file doesn't exist" errors,
+        // syntax errors, etc.
+        return BrsInvalid.Instance;
+    }
+}
+
+/**
+ * Creates several copies of the provided signature and implementation pair, simulating variadic types by creating a
+ * function that accepts zero args, one that accepts one arg, one that accepts two args, (…).
+ *
+ * @param signatureAndImpl the base signature and implementation to make variadic
+ *
+ * @returns an array containing psuedo-variadic versions of the provided signature and implementation
+ */
+function variadic(signatureAndImpl: SignatureAndImplementation): SignatureAndImplementation[] {
+    let { signature, impl } = signatureAndImpl;
+    return [
+        signatureAndImpl,
+        ...new Array(10).fill(0).map((_, numArgs) => {
+            return {
+                signature: {
+                    args: [
+                        ...signature.args,
+                        ...new Array(numArgs)
+                            .fill(0)
+                            .map((_, i) => new StdlibArgument(`arg${i}`, ValueKind.Dynamic)),
+                    ],
+                    returns: signature.returns,
+                },
+                impl: impl,
+            };
+        }),
+    ];
+}
+
+export const Run = new Callable(
+    "Run",
+    ...variadic({
+        signature: {
+            args: [new StdlibArgument("filename", ValueKind.String)],
+            returns: ValueKind.Dynamic,
+        },
+        impl: (interpreter: Interpreter, filename: BrsString, ...args: BrsType[]) => {
+            return runFiles(interpreter, [filename], args);
+        },
+    }),
+    ...variadic({
+        signature: {
+            args: [new StdlibArgument("filenamearray", ValueKind.Object)],
+            returns: ValueKind.Dynamic,
+        },
+        impl: (interpreter: Interpreter, filenamearray: BrsComponent, ...args: BrsType[]) => {
+            if (
+                filenamearray instanceof RoArray &&
+                filenamearray.getElements().every(isBrsString)
+            ) {
+                return runFiles(interpreter, filenamearray.getElements() as BrsString[], args);
+            }
+
+            // RBI seems to hard-reboot when passed a non-empty associative array, but returns invalid for empty
+            // AA's. Let's return invalid to be safe.
+            return BrsInvalid.Instance;
+        },
+    })
+);
diff --git a/src/stdlib/index.ts b/src/stdlib/index.ts
index 45d24f1..c9515f0 100644
--- a/src/stdlib/index.ts
+++ b/src/stdlib/index.ts
@@ -1,5 +1,4 @@
-import { Callable, ValueKind, BrsType, BrsInvalid } from "../brsTypes";
-import { Interpreter } from "../interpreter";
+import { Callable, ValueKind, BrsInvalid } from "../brsTypes";
 
 let warningShown = false;
 
@@ -23,5 +22,6 @@ export * from "./File";
 export * from "./Json";
 export * from "./Math";
 export * from "./Print";
+export * from "./Run";
 export * from "./String";
 export * from "./Type";


Test patch:
diff --git a/test/e2e/StdLib.test.js b/test/e2e/StdLib.test.js
index 682d3b6..4a430ed 100644
--- a/test/e2e/StdLib.test.js
+++ b/test/e2e/StdLib.test.js
@@ -103,4 +103,16 @@ describe("end to end standard libary", () => {
             ].join("\n"),
         ]);
     });
+
+    test("stdlib/run.brs", async () => {
+        await execute([resourceFile("stdlib", "run.brs")], outputStreams);
+
+        expect(allArgs(outputStreams.stdout.write).filter(arg => arg !== "\n")).toEqual([
+            "in run.brs",
+            "    in runme.brs",
+            "returned to run.brs",
+            "runme.brs returned: ",
+            "2",
+        ]);
+    });
 });
diff --git a/test/e2e/resources/stdlib/run.brs b/test/e2e/resources/stdlib/run.brs
new file mode 100644
index 0000000..5add965
--- /dev/null
+++ b/test/e2e/resources/stdlib/run.brs
@@ -0,0 +1,6 @@
+sub main()
+    print "in run.brs"
+    result = Run("pkg:/test/e2e/resources/stdlib/runme.brs")
+    print "returned to run.brs"
+    print "runme.brs returned: " result
+end sub
\ No newline at end of file
diff --git a/test/e2e/resources/stdlib/runme.brs b/test/e2e/resources/stdlib/runme.brs
new file mode 100644
index 0000000..ceeafef
--- /dev/null
+++ b/test/e2e/resources/stdlib/runme.brs
@@ -0,0 +1,8 @@
+function main()
+    print "    in runme.brs"
+    return addOne(1)
+end function
+
+function addOne(a as integer) as integer
+    return a + 1
+end function
\ No newline at end of file
diff --git a/test/stdlib/File.test.js b/test/stdlib/File.test.js
index 629ffc0..94c5294 100644
--- a/test/stdlib/File.test.js
+++ b/test/stdlib/File.test.js
@@ -8,7 +8,7 @@ const {
     FormatDrive,
     ReadAsciiFile,
     WriteAsciiFile,
-    getMemfsPath,
+    getPath,
     getVolumeByPath,
 } = require("../../lib/stdlib/index");
 const { Interpreter } = require("../../lib/interpreter");
@@ -30,8 +30,8 @@ describe("global file I/O functions", () => {
         });
 
         it("converts a brs path to a memfs path", () => {
-            expect(getMemfsPath("tmp:/test.txt")).toEqual("/test.txt");
-            expect(getMemfsPath("tmp:///test.txt")).toEqual("/test.txt");
+            expect(getPath("tmp:/test.txt")).toEqual("/test.txt");
+            expect(getPath("tmp:///test.txt")).toEqual("/test.txt");
         });
     });
 
diff --git a/test/stdlib/Run.test.js b/test/stdlib/Run.test.js
new file mode 100644
index 0000000..6f7116d
--- /dev/null
+++ b/test/stdlib/Run.test.js
@@ -0,0 +1,130 @@
+const brs = require("brs");
+const { BrsString, BrsInvalid, Int32, RoArray, RoAssociativeArray } = brs.types;
+const { Run } = require("../../lib/stdlib");
+const { Interpreter } = require("../../lib/interpreter");
+const fs = require("fs");
+
+jest.mock("fs");
+
+describe("global Run function", () => {
+    let interpreter;
+
+    beforeEach(() => {
+        interpreter = new Interpreter();
+    });
+
+    afterEach(() => {
+        fs.readFileSync.mockRestore();
+    });
+
+    it("returns invalid for unrecognized devices", () => {
+        expect(Run.call(interpreter, new BrsString("notADevice:/etc/hosts"))).toBe(
+            BrsInvalid.Instance
+        );
+    });
+
+    it("returns invalid for unreadable files", () => {
+        fs.readFileSync.mockImplementation(() => {
+            throw new Error("file not found");
+        });
+        expect(Run.call(interpreter, new BrsString("notADevice:/etc/hosts"))).toBe(
+            BrsInvalid.Instance
+        );
+    });
+
+    it("returns invalid for lexing errors", () => {
+        fs.readFileSync.mockImplementation(() => `can't lex this`);
+        expect(Run.call(interpreter, new BrsString("pkg:/errors/lex.brs"))).toBe(
+            BrsInvalid.Instance
+        );
+    });
+
+    it("returns invalid for parse errors", () => {
+        fs.readFileSync.mockImplementationOnce(() => `if return "parse error" exit while`);
+        expect(Run.call(interpreter, new BrsString("pkg:/errors/parse.brs"))).toBe(
+            BrsInvalid.Instance
+        );
+    });
+
+    it("returns invalid for runtime errors", () => {
+        fs.readFileSync.mockImplementationOnce(() => `sub main(): _ = {}: _.crash(): end sub`);
+        expect(Run.call(interpreter, new BrsString("pkg:/errors/exec.brs"))).toBe(
+            BrsInvalid.Instance
+        );
+    });
+
+    it("returns invalid when provided a not-array component", () => {
+        expect(Run.call(interpreter, new RoAssociativeArray([]))).toBe(BrsInvalid.Instance);
+    });
+
+    it("returns invalid when provided an empty array of files", () => {
+        expect(Run.call(interpreter, new RoArray([]))).toBe(BrsInvalid.Instance);
+    });
+
+    it("returns invalid when provided an array with non-strings", () => {
+        expect(Run.call(interpreter, new RoArray([BrsInvalid.Instance]))).toBe(BrsInvalid.Instance);
+    });
+
+    it("returns whatever the executed file returns", () => {
+        fs.readFileSync.mockImplementationOnce(
+            () => `function main(): return "I'm a return value!": end function`
+        );
+        expect(Run.call(interpreter, new BrsString("pkg:/success/exec.brs"))).toEqual(
+            new BrsString("I'm a return value!")
+        );
+    });
+
+    it("returns whatever the executed set of files return", () => {
+        fs.readFileSync
+            .mockImplementationOnce(() => `function main(): return greet(): end function`)
+            .mockImplementationOnce(() => `function greet(): return "hello!": end function`);
+
+        expect(
+            Run.call(
+                interpreter,
+                new RoArray([
+                    new BrsString("pkg:/success/exec.brs"),
+                    new BrsString("pkg:/success/greet.brs"),
+                ])
+            )
+        ).toEqual(new BrsString("hello!"));
+    });
+
+    describe("args", () => {
+        it("accepts one argument", () => {
+            fs.readFileSync.mockImplementationOnce(
+                () => `function main(i as integer): return i: end function`
+            );
+            expect(
+                Run.call(interpreter, new BrsString("pkg:/success/identity.brs"), new Int32(5))
+            ).toEqual(new Int32(5));
+        });
+
+        it("accepts two arguments", () => {
+            fs.readFileSync.mockImplementationOnce(
+                () => `function main(a as integer, b as integer): return a + b: end function`
+            );
+            expect(
+                Run.call(
+                    interpreter,
+                    new BrsString("pkg:/success/identity.brs"),
+                    new Int32(5),
+                    new Int32(3)
+                )
+            ).toEqual(new Int32(8));
+        });
+
+        it("returns invalid for type errors", () => {
+            fs.readFileSync.mockImplementationOnce(
+                () => `function main(i as integer): return i: end function`
+            );
+            expect(
+                Run.call(
+                    interpreter,
+                    new BrsString("pkg:/success/identity.brs"),
+                    new BrsString("not an integer")
+                )
+            ).toEqual(BrsInvalid.Instance);
+        });
+    });
+});