• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / brs / #213

27 Jan 2025 05:30PM UTC coverage: 86.996% (-2.2%) from 89.205%
#213

push

web-flow
Implemented several improvements to SceneGraph (#87)

* Implemented several improvements to SceneGraph

* Fixed most test cases

* Reduced unnecessary code

* Fixed typo

* Added Warning when trying to create a non-existent Node

* Fixed parser

* Fixed unit tests

* Implemented support for `infoFields`

* Prettier fix

* Simplified execute callback code and matched behavior with Roku

* Adding comment to clarify the exception

2240 of 2807 branches covered (79.8%)

Branch coverage included in aggregate %.

139 of 304 new or added lines in 18 files covered. (45.72%)

2 existing lines in 1 file now uncovered.

6129 of 6813 relevant lines covered (89.96%)

27562.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.71
/src/index.ts
1
import * as fs from "fs";
135✔
2
import * as readline from "readline";
135✔
3
import chalk from "chalk";
135✔
4

5
import { promisify } from "util";
135✔
6
const mkdtemp = promisify(fs.mkdtemp);
135✔
7
import decompress from "decompress";
135✔
8
import sanitizeFilename from "sanitize-filename";
135✔
9

10
import { Lexer } from "./lexer";
135✔
11
import * as PP from "./preprocessor";
135✔
12
import { getComponentDefinitionMap, ComponentDefinition, ComponentScript } from "./scenegraph";
135✔
13
import { Parser } from "./parser";
135✔
14
import { Interpreter, ExecutionOptions, defaultExecutionOptions, colorize } from "./interpreter";
135✔
15
import { resetTestData } from "./extensions";
135✔
16
import * as BrsError from "./Error";
135✔
17
import * as LexerParser from "./LexerParser";
135✔
18
import { CoverageCollector } from "./coverage";
135✔
19
import { loadTranslationFiles } from "./stdlib";
135✔
20

21
import * as _lexer from "./lexer";
135✔
22
export { _lexer as lexer };
135✔
23
import * as BrsTypes from "./brsTypes";
135✔
24
export { BrsTypes as types };
135✔
25
export { PP as preprocessor };
135✔
26
import * as _parser from "./parser";
135✔
27
export { _parser as parser };
135✔
28
import { URL } from "url";
135✔
29
import * as path from "path";
135✔
30
import pSettle from "p-settle";
135✔
31
import os from "os";
135✔
32
import { Scope } from "./interpreter/Environment";
135✔
33

34
let coverageCollector: CoverageCollector | null = null;
135✔
35

36
/**
37
 * Executes a BrightScript file by path and writes its output to the streams
38
 * provided in `options`.
39
 *
40
 * @param filename the absolute path to the `.brs` file to be executed
41
 * @param options configuration for the execution, including the streams to use for `stdout` and
42
 *                `stderr` and the base directory for path resolution
43
 *
44
 * @returns a `Promise` that will be resolve if `filename` is successfully
45
 *          executed, or be rejected if an error occurs.
46
 */
47
export async function execute(filenames: string[], options: Partial<ExecutionOptions>) {
135✔
48
    let { lexerParserFn, interpreter } = await loadFiles(options);
107✔
49
    let scripts = new Array<ComponentScript>();
107✔
50
    filenames.forEach((file) => {
107✔
51
        scripts.push({ type: "text/brightscript", uri: file });
118✔
52
    });
53
    let mainStatements = await lexerParserFn(scripts);
107✔
54
    return interpreter.exec(mainStatements);
106✔
55
}
56

57
async function loadFiles(options: Partial<ExecutionOptions>) {
58
    const executionOptions = { ...defaultExecutionOptions, ...options };
112✔
59

60
    let manifest = await PP.getManifest(executionOptions.root);
112✔
61
    let maybeLibraryName = options.isComponentLibrary
112✔
62
        ? manifest.get("sg_component_libs_provided")
63
        : undefined;
64
    if (typeof maybeLibraryName === "boolean") {
112!
65
        throw new Error(
×
66
            "Encountered invalid boolean value for manifest key 'sg_component_libs_provided'"
67
        );
68
    } else if (options.isComponentLibrary && maybeLibraryName == null) {
112!
69
        throw new Error(
×
70
            "Could not find required manifest key 'sg_component_libs_provided' in component library"
71
        );
72
    }
73
    let componentDefinitions = await getComponentDefinitionMap(
112✔
74
        executionOptions.root,
75
        executionOptions.componentDirs,
76
        maybeLibraryName
77
    );
78

79
    let componentLibraries = Array.from(componentDefinitions.values())
112✔
80
        .map((component: ComponentDefinition) => ({
1,755✔
81
            component,
82
            libraries: component.children.filter(
83
                (child) => child.name.toLowerCase() === "componentlibrary"
931✔
84
            ),
85
        }))
86
        .filter(({ libraries }) => libraries && libraries.length > 0);
1,755✔
87

88
    let knownComponentLibraries = new Map<string, string>();
112✔
89
    let componentLibrariesToLoad: ReturnType<typeof loadFiles>[] = [];
112✔
90
    for (let { component, libraries } of componentLibraries) {
112✔
91
        for (let cl of libraries) {
1✔
92
            let uri = cl.fields.uri;
2✔
93
            if (!uri) {
2!
94
                continue;
×
95
            }
96
            if (uri.startsWith("http://") || uri.startsWith("https://")) {
2✔
97
                executionOptions.stderr.write(
1✔
98
                    `WARNING: Only pkg:/-local component libraries are supported; ignoring '${uri}'\n`
99
                );
100
                continue;
1✔
101
            }
102
            let posixRoot = executionOptions.root.replace(/[\/\\]+/g, path.posix.sep);
1✔
103
            let posixPath = component.xmlPath.replace(/[\/\\]+/g, path.posix.sep);
1✔
104
            if (process.platform === "win32") {
1!
UNCOV
105
                posixRoot = posixRoot.replace(/^[a-zA-Z]:/, "");
×
UNCOV
106
                posixPath = posixPath.replace(/^[a-zA-Z]:/, "");
×
107
            }
108

109
            let packageUri = new URL(
1✔
110
                uri,
111
                `pkg:/${path.posix.relative(posixRoot, posixPath)}`
112
            ).toString();
113
            if (knownComponentLibraries.has(packageUri)) {
1!
114
                continue;
×
115
            }
116

117
            let sanitizedUri = sanitizeFilename(packageUri);
1✔
118
            let tempdir = await mkdtemp(path.join(os.tmpdir(), `brs-${sanitizedUri}`), "utf8");
1✔
119
            knownComponentLibraries.set(packageUri, tempdir);
1✔
120

121
            let zipFileOnDisk = path.join(executionOptions.root, new URL(uri).pathname);
1✔
122

123
            componentLibrariesToLoad.push(
1✔
124
                decompress(zipFileOnDisk, tempdir).then(() =>
125
                    loadFiles({
1✔
126
                        ...options,
127
                        root: tempdir,
128
                        componentDirs: [],
129
                        isComponentLibrary: true,
130
                    })
131
                )
132
            );
133
        }
134
    }
135

136
    componentDefinitions.forEach((component: ComponentDefinition) => {
112✔
137
        if (component.scripts.length < 1) return;
1,755✔
138
        try {
1,291✔
139
            component.scripts = component.scripts.map((script: ComponentScript) => {
1,291✔
140
                if (script.uri) {
1,699✔
141
                    script.uri = path.join(executionOptions.root, new URL(script.uri).pathname);
1,699✔
142
                }
143
                return script;
1,699✔
144
            });
145
        } catch (error) {
146
            throw new Error(
×
147
                `Encountered an error when parsing component ${component.name}: ${error}`
148
            );
149
        }
150
    });
151

152
    const interpreter = await Interpreter.withSubEnvsFromComponents(
112✔
153
        componentDefinitions,
154
        manifest,
155
        executionOptions
156
    );
157
    if (!interpreter) {
112!
158
        throw new Error("Unable to build interpreter.");
×
159
    }
160

161
    // Store manifest as a property on the Interpreter for further reusing
162
    interpreter.manifest = manifest;
112✔
163

164
    let componentLibraryInterpreters = (await pSettle(componentLibrariesToLoad))
112✔
165
        .filter((result) => result.isFulfilled)
1✔
166
        .map((result) => result.value!.interpreter);
1✔
167
    interpreter.mergeNodeDefinitionsWith(componentLibraryInterpreters);
112✔
168

169
    await loadTranslationFiles(interpreter, executionOptions.root);
112✔
170
    let lexerParserFn = LexerParser.getLexerParserFn(manifest, options);
112✔
171
    if (executionOptions.generateCoverage) {
112✔
172
        coverageCollector = new CoverageCollector(executionOptions.root, lexerParserFn);
1✔
173
        await coverageCollector.crawlBrsFiles();
1✔
174
        interpreter.setCoverageCollector(coverageCollector);
1✔
175
    }
176
    return { lexerParserFn, interpreter };
112✔
177
}
178

179
/**
180
 * Returns a summary of the code coverage.
181
 */
182
export function getCoverageResults() {
135✔
183
    if (!coverageCollector) return;
1!
184
    return coverageCollector.getCoverage();
1✔
185
}
186

187
/**
188
 * A synchronous version of the lexer-parser flow.
189
 *
190
 * @param filename the paths to BrightScript files to lex and parse synchronously
191
 * @param options configuration for the execution, including the streams to use for `stdout` and
192
 *                `stderr` and the base directory for path resolution
193
 *
194
 * @returns the AST produced from lexing and parsing the provided files
195
 */
196
export function lexParseSync(filenames: string[], options: Partial<ExecutionOptions>) {
135✔
197
    const executionOptions = { ...defaultExecutionOptions, ...options };
23✔
198

199
    let manifest = PP.getManifestSync(executionOptions.root);
23✔
200

201
    return filenames
23✔
202
        .map((filename) => {
203
            let lexer = new Lexer();
22✔
204
            let preprocessor = new PP.Preprocessor();
22✔
205
            let parser = new Parser();
22✔
206
            [lexer, preprocessor, parser].forEach((emitter) =>
22✔
207
                emitter.onError(BrsError.getLoggerUsing(executionOptions.stderr))
66✔
208
            );
209

210
            let contents = fs.readFileSync(filename, "utf8");
22✔
211
            let scanResults = lexer.scan(contents, filename);
22✔
212
            let preprocessorResults = preprocessor.preprocess(scanResults.tokens, manifest);
22✔
213
            let parseResults = parser.parse(preprocessorResults.processedTokens);
22✔
214

215
            if (parseResults.errors.length > 0) {
22✔
216
                throw "Error occurred parsing";
4✔
217
            }
218

219
            return parseResults.statements;
18✔
220
        })
221
        .reduce((allStatements, statements) => [...allStatements, ...statements], []);
18✔
222
}
223

224
export type ExecuteWithScope = (filenames: string[], args: BrsTypes.BrsType[]) => BrsTypes.BrsType;
225
/**
226
 * Runs a set of files to create an execution scope, and generates an execution function that runs in that scope.
227
 *
228
 * @param filenamesForScope List of filenames to put into the execution scope
229
 * @param options Execution options
230
 *
231
 * @returns A function to execute files using the created scope.
232
 */
233
export async function createExecuteWithScope(
135✔
234
    filenamesForScope: string[],
235
    options: Partial<ExecutionOptions>
236
): Promise<ExecuteWithScope> {
237
    let { lexerParserFn, interpreter } = await loadFiles(options);
4✔
238
    let scripts = new Array<ComponentScript>();
4✔
239
    filenamesForScope.forEach((file) => {
4✔
240
        scripts.push({ type: "text/brightscript", uri: file });
4✔
241
    });
242
    let mainStatements = await lexerParserFn(scripts);
4✔
243
    interpreter.exec(mainStatements);
4✔
244
    // Clear any errors that accumulated, so that we can isolate errors from future calls to the execute function.
245
    interpreter.errors = [];
4✔
246

247
    return (filenames: string[], args: BrsTypes.BrsType[]) => {
4✔
248
        // Reset any mocks so that subsequent executions don't interfere with each other.
249
        interpreter.environment.resetMocks();
7✔
250
        resetTestData();
7✔
251

252
        let ast = lexParseSync(filenames, interpreter.options);
7✔
253
        let execErrors: BrsError.BrsError[] = [];
7✔
254
        let returnValue = interpreter.inSubEnv((subInterpreter) => {
7✔
255
            let value = subInterpreter.exec(ast, ...args)[0] || BrsTypes.BrsInvalid.Instance;
7!
256
            execErrors = subInterpreter.errors;
7✔
257
            subInterpreter.errors = [];
7✔
258
            return value;
7✔
259
        });
260

261
        // Re-throw any errors the interpreter encounters. We can't throw them directly from the `inSubEnv` call,
262
        // because they get caught by upstream handlers.
263
        if (execErrors.length) {
7✔
264
            throw execErrors;
2✔
265
        }
266

267
        return returnValue;
5✔
268
    };
269
}
270

271
/**
272
 * Launches an interactive read-execute-print loop, which reads input from
273
 * `stdin` and executes it.
274
 *
275
 * **NOTE:** Currently limited to single-line inputs :(
276
 */
277
export function repl() {
135✔
278
    const replInterpreter = new Interpreter();
×
279
    replInterpreter.onError(BrsError.getLoggerUsing(process.stderr));
×
280

281
    const rl = readline.createInterface({
×
282
        input: process.stdin,
283
        output: process.stdout,
284
    });
285
    rl.setPrompt(`${chalk.magenta("brs")}> `);
×
286
    rl.on("line", (line) => {
×
287
        const cmd = line.trim().toLowerCase();
×
288
        if (["quit", "exit", "q"].includes(cmd)) {
×
289
            process.exit();
×
290
        } else if (["cls", "clear"].includes(cmd)) {
×
291
            process.stdout.write("\x1Bc");
×
292
            rl.prompt();
×
293
            return;
×
294
        } else if (["help", "hint"].includes(cmd)) {
×
295
            printHelp();
×
296
            rl.prompt();
×
297
            return;
×
NEW
298
        } else if (["var", "vars"].includes(line.split(" ")[0]?.toLowerCase().trim())) {
×
NEW
299
            const scopeName = line.split(" ")[1]?.toLowerCase().trim() ?? "function";
×
NEW
300
            let scope = Scope.Function;
×
NEW
301
            if (scopeName === "global") {
×
NEW
302
                scope = Scope.Global;
×
NEW
303
                console.log(chalk.cyanBright(`\r\nGlobal variables:\r\n`));
×
NEW
304
            } else if (scopeName === "module") {
×
NEW
305
                scope = Scope.Module;
×
NEW
306
                console.log(chalk.cyanBright(`\r\nModule variables:\r\n`));
×
307
            } else {
NEW
308
                console.log(chalk.cyanBright(`\r\nLocal variables:\r\n`));
×
309
            }
NEW
310
            console.log(chalk.cyanBright(replInterpreter.formatVariables(scope)));
×
311
            rl.prompt();
×
312
            return;
×
313
        }
314
        let results = run(line, defaultExecutionOptions, replInterpreter);
×
315
        if (results) {
×
316
            results.map((result) => {
×
317
                if (result !== BrsTypes.BrsInvalid.Instance) {
×
318
                    console.log(colorize(result.toString()));
×
319
                }
320
            });
321
        }
322
        rl.prompt();
×
323
    });
324

325
    console.log(colorize("type `help` to see the list of valid REPL commands.\r\n"));
×
326
    rl.prompt();
×
327
}
328

329
/**
330
 * Runs an arbitrary string of BrightScript code.
331
 * @param contents the BrightScript code to lex, parse, and interpret.
332
 * @param options the streams to use for `stdout` and `stderr`. Mostly used for
333
 *                testing.
334
 * @param interpreter an interpreter to use when executing `contents`. Required
335
 *                    for `repl` to have persistent state between user inputs.
336
 * @returns an array of statement execution results, indicating why each
337
 *          statement exited and what its return value was, or `undefined` if
338
 *          `interpreter` threw an Error.
339
 */
340
function run(
341
    contents: string,
342
    options: ExecutionOptions = defaultExecutionOptions,
×
343
    interpreter: Interpreter
344
) {
345
    const lexer = new Lexer();
×
346
    const parser = new Parser();
×
347
    const logErrorFn = BrsError.getLoggerUsing(options.stderr);
×
348

349
    lexer.onError(logErrorFn);
×
350
    parser.onError(logErrorFn);
×
351

352
    const scanResults = lexer.scan(contents, "REPL");
×
353
    if (scanResults.errors.length > 0) {
×
354
        return;
×
355
    }
356

357
    const parseResults = parser.parse(scanResults.tokens);
×
358
    if (parseResults.errors.length > 0) {
×
359
        return;
×
360
    }
361

362
    if (parseResults.statements.length === 0) {
×
363
        return;
×
364
    }
365

366
    try {
×
367
        return interpreter.exec(parseResults.statements);
×
368
    } catch (e) {
369
        //options.stderr.write(e.message);
370
        return;
×
371
    }
372
}
373

374
/**
375
 * Display the help message on the console.
376
 */
377
function printHelp() {
378
    let helpMsg = "\r\n";
×
379
    helpMsg += "REPL Command List:\r\n";
×
NEW
380
    helpMsg += "   print|?           Print variable value or expression\r\n";
×
NEW
381
    helpMsg += "   var|vars [scope]  Display variables and their types/values\r\n";
×
NEW
382
    helpMsg += "   help|hint         Show this REPL command list\r\n";
×
NEW
383
    helpMsg += "   clear|cls         Clear terminal screen\r\n";
×
NEW
384
    helpMsg += "   exit|quit|q       Terminate REPL session\r\n\r\n";
×
385
    helpMsg += "   Type any valid BrightScript expression for a live compile and run.\r\n";
×
386
    console.log(chalk.cyanBright(helpMsg));
×
387
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc