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

rokucommunity / roku-debug / 28819368783

06 Jul 2026 07:58PM UTC coverage: 73.029% (+0.3%) from 72.68%
28819368783

Pull #323

github

web-flow
Merge 5556afc82 into 1f928695c
Pull Request #323: apply postfix for Library statement

3846 of 5496 branches covered (69.98%)

Branch coverage included in aggregate %.

93 of 120 new or added lines in 3 files covered. (77.5%)

355 existing lines in 1 file now uncovered.

6021 of 8015 relevant lines covered (75.12%)

47.42 hits per line

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

98.06
/src/CompileErrorProcessor.ts
1
import { EventEmitter } from 'events';
2✔
2
import { logger } from './logging';
2✔
3
import type { DiagnosticTag, Range } from 'brighterscript';
4
import { DiagnosticSeverity, util as bscUtil } from 'brighterscript';
2✔
5

6
export class CompileErrorProcessor {
2✔
7

8
    private logger = logger.createLogger(`[${CompileErrorProcessor.name}]`);
85✔
9
    public status: CompileStatus = CompileStatus.none;
85✔
10
    public startCompilingLine = -1;
85✔
11
    public endCompilingLine = -1;
85✔
12
    public compilingLines = [] as string[];
85✔
13
    public compileErrorTimeoutMs = 1000;
85✔
14
    private emitter = new EventEmitter();
85✔
15
    public compileErrorTimer: NodeJS.Timeout;
16

17
    /**
18
     * While paused, incoming device lines are ignored and no `diagnostics` events are emitted. Used to suppress the
19
     * compile errors the device produces as a side effect of deleting interdependent component libraries.
20
     */
21
    private isPaused = false;
85✔
22

23
    /**
24
     * How long to wait for device output to "settle" (stop arriving) so in-flight lines drain before we pause, and
25
     * any deletion-induced output drains before we resume.
26
     */
27
    public settleTimeMs = 300;
85✔
28

29
    /**
30
     * Stop processing incoming lines and emitting diagnostics. Resets any in-progress compile tracking so a
31
     * half-captured compile can't fire after we resume. Pair with `resume()`.
32
     */
33
    public pause() {
34
        this.isPaused = true;
3✔
35
        this.resetCompileErrorTimer(false);
3✔
36
        this.status = CompileStatus.none;
3✔
37
        this.compilingLines = [];
3✔
38
    }
39

40
    /**
41
     * Resume processing incoming lines. Starts from a clean compile-tracking state so output from before the pause
42
     * doesn't bleed into a new compile.
43
     */
44
    public resume() {
45
        this.status = CompileStatus.none;
1✔
46
        this.compilingLines = [];
1✔
47
        this.isPaused = false;
1✔
48
    }
49

50
    /**
51
     * Wait for the configured settle time so device output can drain. Use it around pause/resume:
52
     * `pause()` → `settle()` (let pre-pause output flush) → do the noisy work → `settle()` → `resume()`.
53
     */
54
    public settle(ms = this.settleTimeMs) {
×
55
        return new Promise<void>(resolve => {
1✔
56
            setTimeout(resolve, ms);
1✔
57
        });
58
    }
59

60
    public on(eventName: 'diagnostics', handler: (params: BSDebugDiagnostic[]) => void);
61
    public on(eventName: 'launch-status', handler: (message: string) => void);
62
    public on(eventName: string, handler: (payload: any) => void) {
63
        this.emitter.on(eventName, handler);
34✔
64
        return () => {
34✔
65
            if (this.emitter !== undefined) {
2✔
66
                this.emitter.removeListener(eventName, handler);
1✔
67
            }
68
        };
69
    }
70

71
    private emit(eventName: 'diagnostics', data?: BSDebugDiagnostic[]): void;
72
    private emit(eventName: 'launch-status', message: string): void;
73
    private emit(eventName: string, data?: unknown): void {
74
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
75
        setTimeout(() => {
98✔
76
            //suppress diagnostics while paused so a timer that fires mid-pause can't surface deletion-induced errors
77
            if (this.isPaused && eventName === 'diagnostics') {
98!
NEW
78
                return;
×
79
            }
80
            //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
81
            this.emitter?.emit?.(eventName, data);
98✔
82
        }, 0);
83
    }
84

85
    public processUnhandledLines(responseText: string) {
86
        //while paused (e.g. during component-library deletion), ignore device output so deletion-induced compile
87
        //errors are never tracked or surfaced
88
        if (this.isPaused) {
160✔
89
            return;
1✔
90
        }
91
        let lines = responseText.split(/\r?\n/g);
159✔
92
        for (const line of lines) {
159✔
93
            this.checkForLaunchStatus(line);
272✔
94
            switch (this.status) {
272✔
95
                case CompileStatus.compiling:
96
                case CompileStatus.compileError:
97
                    if (this.isEndCompilingLine(line)) {
199✔
98
                        this.logger.debug('[processUnhandledLines] entering state CompileStatus.running');
3✔
99
                        this.status = CompileStatus.running;
3✔
100
                        this.resetCompileErrorTimer(false);
3✔
101
                    } else {
102
                        this.compilingLines.push(line);
196✔
103
                        if (this.status === CompileStatus.compiling) {
196✔
104
                            //check to see if we've entered an error scenario
105
                            let hasError = /\berror\b/gi.test(line);
110✔
106
                            if (hasError) {
110✔
107
                                this.logger.debug('[processUnhandledLines] entering state CompileStatus.compileError');
13✔
108
                                this.status = CompileStatus.compileError;
13✔
109
                            }
110
                        }
111
                        if (this.status === CompileStatus.compileError) {
196✔
112
                            //every input line while in error status will reset the stale timer, so we can wait for more errors to roll in.
113
                            this.resetCompileErrorTimer(true);
99✔
114
                        }
115
                    }
116
                    break;
199✔
117
                case CompileStatus.none:
118
                case CompileStatus.running:
119
                    if (this.isStartingCompilingLine(line)) {
73✔
120
                        this.logger.debug('[processUnhandledLines] entering state CompileStatus.compiling');
16✔
121
                        this.status = CompileStatus.compiling;
16✔
122
                        this.resetCompileErrorTimer(true);
16✔
123
                    }
124
                    break;
73✔
125
            }
126
        }
127
    }
128

129
    /**
130
     * Checks a single log line for known launch phase signals and emits a `launch-status`
131
     * event with a human-readable progress message when one is found.
132
     */
133
    private checkForLaunchStatus(line: string) {
134
        let message: string | undefined;
135
        if (/\[beacon\.signal\]\s+\|AppLaunchInitiate/i.test(line)) {
272✔
136
            message = 'Starting';
16✔
137
        } else if (/\[beacon\.signal\]\s+\|AppCompileInitiate/i.test(line)) {
256✔
138
            message = 'Compiling';
14✔
139
        } else if (/\[scrpt\.load\.mkup\]/i.test(line)) {
242✔
140
            message = 'Loading markup';
17✔
141
        } else if (/\[scrpt\.ctx\.cmpl\.time\]\s+Compiled\b/i.test(line)) {
225✔
142
            message = 'Scripts compiled';
10✔
143
        } else if (/\[beacon\.signal\]\s+\|AppCompileComplete/i.test(line)) {
215✔
144
            message = 'Compile complete';
7✔
145
        } else if (/\[beacon\.signal\]\s+\|AppSplashInitiate/i.test(line)) {
208✔
146
            message = 'Showing splash screen';
4✔
147
        } else if (/\[beacon\.signal\]\s+\|AppSplashComplete/i.test(line)) {
204✔
148
            message = 'Splash screen complete';
4✔
149
        } else if (/------\s+Running.*------/i.test(line)) {
200✔
150
            message = 'Running';
5✔
151
        } else if (/\[plg\.dbg\.conn\.wait\]\s+Waiting for debug(?:ging)? connection/i.test(line)) {
195✔
152
            message = 'Waiting for debugger';
3✔
153
        } else if (/\[plg\.dbg\.conn\.ok\]\s+remote debugger connected/i.test(line)) {
192✔
154
            message = 'Debugger connected';
3✔
155
        }
156
        if (message) {
272✔
157
            this.emit('launch-status', message);
83✔
158
        }
159
    }
160

161
    public sendErrors(): Promise<void> {
162
        //session is shutting down, process logs immediately
163
        //HACK: leave time for events and errors resolvers to run,
164
        //otherwise the staging folder will have been deleted
165
        return new Promise<void>(resolve => {
1✔
166
            this.onCompileErrorTimer();
1✔
167
            setTimeout(resolve, 500);
1✔
168
        });
169
    }
170

171
    public getErrors(lines: string[]) {
172
        let diagnostics: BSDebugDiagnostic[] = [];
31✔
173
        //clone the lines so the parsers can manipulate them
174
        lines = [...lines];
31✔
175
        while (lines.length > 0) {
31✔
176
            const startLength = lines.length;
198✔
177
            const line = lines[0];
198✔
178

179
            if (line) {
198✔
180
                diagnostics.push(
166✔
181
                    ...[
182
                        this.processMultiLineErrors(lines),
183
                        this.parseComponentDefinedInFileError(lines),
184
                        this.parseGenericXmlError(line),
185
                        this.parseSyntaxAndCompileErrors(line),
186
                        this.parseMissingManifestError(line)
187
                    ].flat().filter(x => !!x)
675✔
188
                );
189
            }
190
            //if none of the parsers consumed a line, remove the first line
191
            if (lines.length === startLength) {
198✔
192
                lines.shift();
188✔
193
            }
194
        }
195
        //throw out $livecompile errors (those are generated by REPL/eval code)
196
        const result = diagnostics.filter(x => {
31✔
197
            return x.path && !x.path.toLowerCase().includes('$livecompile');
43✔
198

199
            //dedupe compile errors that have the same information
200
        }).reduce((map, d) => {
201
            map.set(`${d.path}:${d.range?.start.line}:${d.range?.start.character}-${d.message}-${d.severity}-${d.source}`, d);
42!
202
            return map;
42✔
203
        }, new Map<string, BSDebugDiagnostic>());
204

205
        return [...result].map(x => x[1]);
42✔
206
    }
207

208
    /**
209
     * Parse generic xml errors with no further context below
210
     */
211
    public parseGenericXmlError(line: string): BSDebugDiagnostic[] {
212
        let [, message, files] = this.execAndTrim(
166✔
213
            // https://regex101.com/r/LDUyww/3
214
            /^(?:-+\>)?\s*(Error parsing (?:multiple )?XML component[s]?)\s+\(?(.+\.xml)\)?.*$/igm,
215
            line
216
        ) ?? [];
217
        if (message && typeof files === 'string') {
166✔
218
            //use the singular xml parse message since the plural doesn't make much sense when attached to a single file
219
            if (message.toLowerCase() === 'error parsing multiple xml components') {
10✔
220
                message = 'Error parsing XML component';
6✔
221
            }
222
            //there can be 1 or more file paths, so add a distinct error for each one
223
            return files.split(',')
10✔
224
                .map(filePath => ({
15✔
225
                    path: this.sanitizeCompilePath(filePath),
226
                    range: bscUtil.createRange(0, 0, 0, 999),
227
                    message: this.buildMessage(message),
228
                    code: undefined,
229
                    severity: DiagnosticSeverity.Error
230
                }))
231
                .filter(x => !!x);
15✔
232
        }
233
    }
234

235
    /**
236
     * Parse the standard syntax and compile error format
237
     */
238
    private parseSyntaxAndCompileErrors(line: string): BSDebugDiagnostic[] {
239
        let [, message, errorType, code, trailingInfo] = this.execAndTrim(
166✔
240
            // https://regex101.com/r/HHZ6dE/3
241
            /(.*?)(?:\(((?:syntax|compile)\s+error)\s+(&h[\w\d]+)?\s*\))\s*in\b\s+(.+)/ig,
242
            line
243
        ) ?? [];
244

245
        if (message) {
166✔
246
            //split the file path, line number, and trailing context if available.
247
            let [, filePath, lineNumber, context] = this.execAndTrim(
19✔
248
                /(.+)\((\d+)?\)(.*)/ig,
249
                trailingInfo
250
                //default the `filePath` var to the whole `trailingInfo` string
251
            ) ?? [null, trailingInfo, null, null];
252

253
            return [{
19✔
254
                path: this.sanitizeCompilePath(filePath),
255
                message: this.buildMessage(message, context),
256
                range: this.getRange(lineNumber), //lineNumber is 1-based
257
                code: code,
258
                severity: DiagnosticSeverity.Error
259
            }];
260
        }
261
    }
262

263
    /**
264
     * Handles when an error lists the filename on the first line, then subsequent lines each have 1 error.
265
     * Stops on the first line that doesn't have an error line. Like this:
266
     * ```
267
     *  Found 3 parse errors in XML file Foo.xml
268
     *  --- Line 2: Unexpected data found inside a <component> element (first 10 characters are "aaa")
269
     *  --- Line 3: Some unique error message
270
     *  --- Line 5: message with Line 4 inside it
271
     */
272
    private processMultiLineErrors(lines: string[]): BSDebugDiagnostic[] {
273
        const errors = [];
166✔
274
        let [, count, filePath] = this.execAndTrim(
166✔
275
            // https://regex101.com/r/wBMp8B/1
276
            /found (\d+).*error[s]? in.*?file(.*)/gmi,
277
            lines[0]
278
        ) ?? [];
279
        filePath = this.sanitizeCompilePath(filePath);
166✔
280
        if (filePath) {
166✔
281
            let i = 0;
8✔
282
            //parse each line that looks like it's an error.
283
            for (i = 1; i < lines.length; i++) {
8✔
284
                //example: `Line 1: Unexpected data found inside a <component> element (first 10 characters are "aaa")`)
285
                const [, lineNumber, message] = this.execAndTrim(
14✔
286
                    /^[\-\s]*line (\d*):(.*)$/gim,
287
                    lines[i]
288
                ) ?? [];
289
                if (lineNumber && message) {
14✔
290
                    errors.push({
6✔
291
                        path: filePath,
292
                        range: this.getRange(lineNumber), //lineNumber is 1-based
293
                        message: this.buildMessage(message),
294
                        code: undefined,
295
                        severity: DiagnosticSeverity.Error
296
                    });
297
                } else {
298
                    //assume there are no more errors for this file
299
                    break;
8✔
300
                }
301
            }
302
            //remove the lines we consumed
303
            lines.splice(0, i);
8✔
304
        }
305
        return errors;
166✔
306
    }
307

308
    /**
309
     * Parse errors that look like this:
310
     * ```
311
     * Error in XML component RedButton defined in file pkg:/components/RedButton.xml
312
     * -- Extends type does not exist: "ColoredButton"
313
     */
314
    private parseComponentDefinedInFileError(lines: string[]): BSDebugDiagnostic[] {
315
        let [, message, filePath] = this.execAndTrim(
166✔
316
            /(Error in XML component [a-z0-9_-]+) defined in file (.*)/i,
317
            lines[0]
318
        ) ?? [];
319
        if (filePath) {
166✔
320
            lines.shift();
2✔
321
            //assume the next line includes the actual error message
322
            if (lines[0]) {
2✔
323
                message = lines.shift();
1✔
324
            }
325
            return [{
2✔
326
                message: this.buildMessage(message),
327
                path: this.sanitizeCompilePath(filePath),
328
                range: this.getRange(),
329
                code: undefined,
330
                severity: DiagnosticSeverity.Error
331
            }];
332
        }
333
    }
334

335
    /**
336
     * Parse error messages that look like this:
337
     * ```
338
     * ------->No manifest. Invalid package.
339
     * ```
340
     */
341
    private parseMissingManifestError(line: string): BSDebugDiagnostic[] {
342
        let [, message] = this.execAndTrim(
166✔
343
            // https://regex101.com/r/ANr5xd/1
344
            /^(?:-+)>(No manifest\. Invalid package\.)/i
345
            ,
346
            line
347
        ) ?? [];
348
        if (message) {
166✔
349
            return [{
1✔
350
                path: 'pkg:/manifest',
351
                range: bscUtil.createRange(0, 0, 0, 999),
352
                message: this.buildMessage(message),
353
                code: undefined,
354
                severity: DiagnosticSeverity.Error
355
            }];
356
        }
357
    }
358

359
    /**
360
     * Exec the regexp, and if there's a match, trim every group
361
     */
362
    private execAndTrim(pattern: RegExp, text: string) {
363
        return pattern.exec(text)?.map(x => x?.trim());
863✔
364
    }
365

366
    private buildMessage(message: string, context?: string) {
367
        //remove any leading dashes or whitespace
368
        message = message.replace(/^[ \t\-]+/g, '');
43✔
369

370
        //append context to end of message (if available)
371
        if (context?.length > 0) {
43✔
372
            message += ' ' + context;
5✔
373
        }
374
        //remove trailing period from message
375
        message = message.replace(/[\s.]+$/, '');
43✔
376

377
        return message;
43✔
378
    }
379

380
    /**
381
     * Given a text-based line number, convert it to a number and return a range.
382
     * Defaults to line number 1 (1-based) if unable to parse.
383
     * @returns a zero-based vscode `Range` object
384
     */
385
    private getRange(lineNumberText?: string) {
386
        //convert the line number to an integer (if applicable)
387
        let lineNumber = parseInt(lineNumberText); //1-based
27✔
388
        lineNumber = isNaN(lineNumber) ? 1 : lineNumber;
27✔
389
        return bscUtil.createRange(lineNumber - 1, 0, lineNumber - 1, 999);
27✔
390
    }
391

392
    /**
393
     * Trim all leading junk up to the `pkg:/` in this string
394
     */
395
    public sanitizeCompilePath(debuggerPath: string): string {
396
        return debuggerPath?.replace(/.*?(?=pkg:\/)/, '')?.trim();
202✔
397
    }
398

399
    public resetCompileErrorTimer(isRunning): any {
400
        if (this.compileErrorTimer) {
133✔
401
            clearTimeout(this.compileErrorTimer);
99✔
402
            this.compileErrorTimer = undefined;
99✔
403
        }
404

405
        if (isRunning) {
133✔
406
            if (this.status === CompileStatus.compileError) {
115✔
407
                this.compileErrorTimer = setTimeout(() => {
99✔
408
                    this.onCompileErrorTimer();
11✔
409
                }, this.compileErrorTimeoutMs);
410
            }
411
        }
412
    }
413

414
    public onCompileErrorTimer() {
415
        this.status = CompileStatus.compileError;
12✔
416
        this.resetCompileErrorTimer(false);
12✔
417
        this.reportErrors();
12✔
418
    }
419

420
    private isStartingCompilingLine(line: string): boolean {
421
        //https://regex101.com/r/8W2wuZ/1
422
        // We need to start scanning for compile errors earlier than the ---compiling--- message, so look for the [scrpt.cmpl] message.
423
        // keep the ---compiling--- as well, since it doesn't hurt to remain in compile mode
424
        return /(------\s+compiling.*------)|(\[scrpt.cmpl]\s+compiling\s+'.*?'\s*,\s*id\s*'.*?')/i.test(line);
73✔
425
    }
426

427
    private isEndCompilingLine(line: string): boolean {
428
        // if this line looks like the compiling line
429
        return /------\s+Running.*------/i.test(line);
199✔
430
    }
431

432
    /**
433
     * Look through the given responseText for a compiler error
434
     * @param responseText
435
     */
436
    private reportErrors() {
437
        const errors = this.getErrors(this.compilingLines);
13✔
438
        if (errors.length > 0) {
13✔
439
            this.emit('diagnostics', errors);
12✔
440
        }
441
    }
442

443
    public destroy() {
444
        if (this.emitter) {
50✔
445
            this.emitter.removeAllListeners();
49✔
446
        }
447
    }
448
}
449

450
export interface BSDebugDiagnostic {
451
    /**
452
     * The diagnostic's message. It usually appears in the user interface
453
     */
454
    message: string;
455
    /**
456
     * The range at which the message applies
457
     */
458
    range: Range;
459
    /**
460
     * The diagnostic's code, which usually appear in the user interface.
461
     */
462
    code?: number | string;
463
    /**
464
     * An optional property to describe the error code.
465
     * Requires the code field (above) to be present/not null.
466
     *
467
     * @since 3.16.0
468
     */
469
    codeDescription?: {
470
        /**
471
         * An URI to open with more information about the diagnostic error.
472
         */
473
        href: string;
474
    };
475
    /**
476
     * A human-readable string describing the source of this
477
     * diagnostic, e.g. 'typescript' or 'super lint'. It usually
478
     * appears in the user interface.
479
     */
480
    source?: string;
481
    /**
482
     * Path to the file in question. When emitted from a Roku device, this will be a full pkgPath (i.e. `pkg:/source/main.brs`).
483
     * As it flows through the program, this may be modified to represent a source location (i.e. `C:/projects/app/source/main.brs`)
484
     */
485
    path: string;
486
    /**
487
     * The name of the component library this diagnostic was emitted from. Should be undefined if diagnostic originated from the
488
     * main app.
489
     */
490
    componentLibraryName?: string;
491
    /**
492
     * The diagnostic's severity. Can be omitted. If omitted it is up to the
493
     * client to interpret diagnostics as error, warning, info or hint.
494
     */
495
    severity?: DiagnosticSeverity;
496

497
    /**
498
     * Additional metadata about the diagnostic.
499
     *
500
     * @since 3.15.0
501
     */
502
    tags?: DiagnosticTag[];
503
    /**
504
     * An array of related diagnostic information, e.g. when symbol-names within
505
     * a scope collide all definitions can be marked via this property.
506
     */
507
    relatedInformation?: Array<{
508
        /**
509
         * The location of this related diagnostic information.
510
         */
511
        location: {
512
            uri: string;
513
            range: Range;
514
        };
515
        /**
516
         * The message of this related diagnostic information.
517
         */
518
        message: string;
519
    }>;
520
    /**
521
     * A data entry field that is preserved between a `textDocument/publishDiagnostics`
522
     * notification and `textDocument/codeAction` request.
523
     *
524
     * @since 3.16.0
525
     */
526
    data?: any;
527
}
528

529
export enum CompileStatus {
2✔
530
    none = 'none',
2✔
531
    compiling = 'compiling',
2✔
532
    compileError = 'compileError',
2✔
533
    running = 'running'
2✔
534
}
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

© 2026 Coveralls, Inc