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

rokucommunity / roku-debug / 28251691066

26 Jun 2026 04:36PM UTC coverage: 72.684% (+1.7%) from 70.995%
28251691066

push

github

web-flow
Improve debug console completions (#376)

3730 of 5366 branches covered (69.51%)

Branch coverage included in aggregate %.

134 of 136 new or added lines in 1 file covered. (98.53%)

5905 of 7890 relevant lines covered (74.84%)

46.86 hits per line

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

65.99
/src/debugSession/BrightScriptDebugSession.ts
1
import * as fsExtra from 'fs-extra';
2✔
2
import { orderBy } from 'natural-orderby';
2✔
3
import * as path from 'path';
2✔
4
import * as semver from 'semver';
2✔
5
import { rokuDeploy, CompileError, isUpdateCheckRequiredError, isConnectionResetError, EcpNetworkAccessModeDisabledError } from 'roku-deploy';
2✔
6
import type { DeviceInfo, RokuDeploy, RokuDeployOptions } from 'roku-deploy';
7
import {
2✔
8
    BreakpointEvent,
9
    LoggingDebugSession,
10
    Logger as DapLogger,
11
    logger as dapLogger,
12
    CapabilitiesEvent,
13
    InitializedEvent,
14
    InvalidatedEvent,
15
    OutputEvent,
16
    ProgressEndEvent,
17
    ProgressStartEvent,
18
    ProgressUpdateEvent,
19
    Source,
20
    StackFrame,
21
    StoppedEvent,
22
    TerminatedEvent,
23
    Thread,
24
    Variable
25
} from '@vscode/debugadapter';
26
import type { SceneGraphCommandResponse } from '../SceneGraphDebugCommandController';
27
import { SceneGraphDebugCommandController } from '../SceneGraphDebugCommandController';
2✔
28
import type { DebugProtocol } from '@vscode/debugprotocol';
29
import { defer, util } from '../util';
2✔
30
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
31
import { ComponentLibraryServer } from '../ComponentLibraryServer';
2✔
32
import { ProjectManager, Project, ComponentLibraryProject } from '../managers/ProjectManager';
2✔
33
import type { EvaluateContainer, Thread as AdapterThread } from '../adapters/DebugProtocolAdapter';
34
import { DebugProtocolAdapter } from '../adapters/DebugProtocolAdapter';
2✔
35
import { TelnetAdapter } from '../adapters/TelnetAdapter';
2✔
36
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
37
import { RendezvousTracker } from '../RendezvousTracker';
2✔
38
import {
2✔
39
    LaunchStartEvent,
40
    LogOutputEvent,
41
    RendezvousEvent,
42
    DiagnosticsEvent,
43
    StoppedEventReason,
44
    ChanperfEvent,
45
    DebugServerLogOutputEvent,
46
    ChannelPublishedEvent,
47
    CustomRequestEvent,
48
    ClientToServerCustomEventName,
49
    ProfilingErrorEvent,
50
    ProfilingStartEvent,
51
    ProfilingStopEvent,
52
    ProfilingEnabledEvent as ProfilingEnableEvent,
53
    ProcessCrashEvent
54
} from './Events';
55
import type { ProcessCrashEventData } from './Events';
56
import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration';
57
import { FileManager } from '../managers/FileManager';
2✔
58
import { SourceMapManager } from '../managers/SourceMapManager';
2✔
59
import { LocationManager } from '../managers/LocationManager';
2✔
60
import type { AugmentedSourceBreakpoint } from '../managers/BreakpointManager';
61
import { BreakpointManager } from '../managers/BreakpointManager';
2✔
62
import type { LogMessage } from '../logging';
63
import { PerfettoManager } from '../PerfettoManager';
2✔
64
import { logger, FileLoggingManager, debugServerLogOutputEventTransport, LogLevelPriority } from '../logging';
2✔
65
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2✔
66
import { DiagnosticSeverity } from 'brighterscript';
2✔
67
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
68
import { debounce } from 'debounce';
2✔
69
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
2✔
70
import { globalCallables } from 'brighterscript/dist/globalCallables';
2✔
71
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
2✔
72
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
2✔
73
import { AppState, rokuECP } from '../RokuECP';
2✔
74
import { SocketConnectionInUseError } from '../Exceptions';
2✔
75

76
const diagnosticSource = 'roku-debug';
2✔
77

78
/**
79
 * Sort tiers for debug-console completions. Lower values sort first, so a variable's own members rank
80
 * above interface methods, then the file's scope functions, and finally the (large) set of globals.
81
 */
82
enum CompletionSortTier {
2✔
83
    Member = '1',
2✔
84
    Method = '2',
2✔
85
    ScopeFunction = '3',
2✔
86
    Global = '4'
2✔
87
}
88

89
export class BrightScriptDebugSession extends LoggingDebugSession {
2✔
90
    public constructor() {
91
        super();
199✔
92

93
        // this debugger uses one-based lines and columns
94
        this.setDebuggerLinesStartAt1(false);
199✔
95
        this.setDebuggerColumnsStartAt1(false);
199✔
96

97
        //give util a reference to this session to assist in logging across the entire module
98
        util._debugSession = this;
199✔
99

100
        this.fileManager = new FileManager();
199✔
101
        this.sourceMapManager = new SourceMapManager();
199✔
102
        this.locationManager = new LocationManager(this.sourceMapManager);
199✔
103
        this.breakpointManager = new BreakpointManager(this.sourceMapManager, this.locationManager);
199✔
104
        //send newly-verified breakpoints to vscode
105
        this.breakpointManager.on('breakpoints-verified', (data) => this.onDeviceBreakpointsChanged('changed', data));
199✔
106
        this.projectManager = new ProjectManager({
199✔
107
            breakpointManager: this.breakpointManager,
108
            locationManager: this.locationManager
109
        });
110
        this.fileLoggingManager = new FileLoggingManager();
199✔
111
    }
112

113
    public start(inStream: NodeJS.ReadableStream, outStream: NodeJS.WritableStream): void {
114
        super.start(inStream, outStream);
2✔
115
        // Set up DAP protocol logging as early as possible — immediately after start() so we capture
116
        // the initialize request and all early DAP traffic before launchRequest config is available.
117
        // The log file path is injected as ROKU_DAP_LOG_FILE by the extension's DebugAdapterDescriptorFactory,
118
        // which resolves the path from the `brightscript.debug.debugAdapterProtocolLogging` workspace setting
119
        // (or the equivalent launch.json property) before the debug adapter process is spawned.
120
        const dapLogFile = process.env.ROKU_DAP_LOG_FILE;
2✔
121
        if (dapLogFile) {
2✔
122
            // Use LogLevel.Error (not Verbose) as the console threshold so DAP messages are written
123
            // to the log file but are NOT forwarded to VS Code as OutputEvents, which would flood
124
            // the debug console and break the extension's output parsing.
125
            // Note: InternalLogger always writes ALL messages to the file stream regardless of level,
126
            // so the log file will still contain everything.
127
            dapLogger.setup(DapLogger.LogLevel.Error, dapLogFile);
1✔
128
        }
129
    }
130

131
    public setupProcessErrorHandlers() {
132
        if (this.processErrorHandlersRegistered) {
16✔
133
            return;
1✔
134
        }
135
        this.processErrorHandlersRegistered = true;
15✔
136

137
        const handleError = (type: 'uncaughtException' | 'unhandledRejection', error: unknown) => {
15✔
138
            const logger = this.logger.createLogger(`${type}`);
14✔
139
            const message = error instanceof Error ? error.message : String(error);
14✔
140
            const stack = error instanceof Error ? error.stack : undefined;
14✔
141
            logger.error(message, stack);
14✔
142

143
            let output: string;
144
            let debuggerVersion: string;
145
            let additionalInfo: ProcessCrashEventData['additionalInfo'];
146
            try {
14✔
147
                debuggerVersion = (fsExtra.readJsonSync(path.resolve(__dirname, '../../package.json')) as { version: string }).version;
14✔
148

149
                const clientName = this.initRequestArgs?.clientName ?? 'unknown';
13✔
150

151
                additionalInfo = {
13✔
152
                    clientName: clientName,
153
                    rokuDebugVersion: debuggerVersion,
154
                    ecpMode: this.deviceInfo?.ecpSettingMode,
39!
155
                    developerMode: this.deviceInfo?.developerEnabled,
39!
156
                    firmware: this.deviceInfo ? `${this.deviceInfo?.softwareVersion}.${this.deviceInfo?.softwareBuild}` : undefined,
13!
157
                    protocolVersion: this.deviceInfo?.brightscriptDebuggerVersion,
39!
158
                    protocolEnabled: this.enableDebugProtocol
159
                };
160

161
                const lines = Object.entries(additionalInfo as Record<string, unknown>).map(([key, value]) => {
13✔
162
                    // Insert a space before all uppercase letters preceded by a lowercase letter, then uppercase the first char
163
                    const spacedString = key.replace(/([a-z])([A-Z])/g, '$1 $2');
91✔
164
                    const formattedKey = spacedString.charAt(0).toUpperCase() + spacedString.slice(1);
91✔
165
                    return `**${formattedKey}:** ${JSON.stringify(value)}`;
91✔
166
                });
167

168
                const issueBodyPrefix = [
13✔
169
                    `**Error type:** ${type}`,
170
                    `**Message:** ${message}`,
171
                    ...lines,
172
                    '',
173
                    `**Steps to reproduce:**`,
174
                    `<!-- Please describe what you were doing when this crash occurred -->`,
175
                    '',
176
                    '**Stack trace:**',
177
                    '```',
178
                    ''
179
                ].join('\n');
180
                const issueBodySuffix = '\n```';
13✔
181

182
                const issueTitle = encodeURIComponent(`[crash] ${type}: ${message}`);
13✔
183
                const baseUrl = 'https://github.com/RokuCommunity/roku-debug/issues/new';
13✔
184
                const maxUrlLength = 2000;
13✔
185
                const urlOverhead = `${baseUrl}?title=${issueTitle}&body=`.length;
13✔
186
                const bodyBudget = maxUrlLength - urlOverhead;
13✔
187
                const encodedPrefix = encodeURIComponent(issueBodyPrefix);
13✔
188
                const encodedSuffix = encodeURIComponent(issueBodySuffix);
13✔
189
                const stackBudget = bodyBudget - encodedPrefix.length - encodedSuffix.length;
13✔
190
                let truncatedStack: string;
191
                if (!stack) {
13✔
192
                    truncatedStack = '(no stack trace)';
2✔
193
                } else if (encodeURIComponent(stack).length <= stackBudget) {
11✔
194
                    truncatedStack = stack;
3✔
195
                } else {
196
                    truncatedStack = decodeURIComponent(encodeURIComponent(stack).slice(0, stackBudget)) + '\n...(truncated)';
8✔
197
                }
198
                const issueUrl = `${baseUrl}?title=${issueTitle}&body=${encodedPrefix}${encodeURIComponent(truncatedStack)}${encodedSuffix}`;
10✔
199

200
                output = [
10✔
201
                    '',
202
                    '================================================================',
203
                    '\tBRIGHTSCRIPT DEBUGGER INTERNAL ERROR',
204
                    '\tThis is a crash in the debug adapter, not in your application.',
205
                    '================================================================',
206
                    `\tError type: ${type}`,
207
                    `\tMessage: ${message}`,
208
                    ...lines.map(l => `\t${l}`),
70✔
209
                    '',
210
                    '\tStack trace:',
211
                    ...(stack ?? '(no stack trace)').split('\n').map(l => `\t${l}`),
73✔
212
                    '',
213
                    '\tPlease report this at:',
214
                    `\t${issueUrl}`,
215
                    '================================================================',
216
                    ''
217
                ].join('\n');
218
            } catch (e) {
219
                output = JSON.stringify({
4✔
220
                    name: e.name,
221
                    message: e.message,
222
                    stack: e.stack
223
                });
224
            }
225

226
            void this.sendLogOutput(output).catch(() => { /** best-effort */ });
14✔
227
            this.isCrashed = true;
14✔
228
            this.sendEvent(new ProcessCrashEvent({ type, message, stack, additionalInfo: additionalInfo ?? {} }));
14✔
229
            setTimeout(() => void this.shutdown(), 5000);
14✔
230
        };
231

232
        this._uncaughtExceptionHandler = (error) => handleError('uncaughtException', error);
15✔
233
        this._unhandledRejectionHandler = (reason) => handleError('unhandledRejection', reason);
15✔
234

235
        process.on('uncaughtException', this._uncaughtExceptionHandler);
15✔
236
        process.on('unhandledRejection', this._unhandledRejectionHandler);
15✔
237
    }
238

239
    public teardownProcessErrorHandlers() {
240
        if (this._uncaughtExceptionHandler) {
30✔
241
            process.removeListener('uncaughtException', this._uncaughtExceptionHandler);
15✔
242
            this._uncaughtExceptionHandler = undefined;
15✔
243
        }
244
        if (this._unhandledRejectionHandler) {
30✔
245
            process.removeListener('unhandledRejection', this._unhandledRejectionHandler);
15✔
246
            this._unhandledRejectionHandler = undefined;
15✔
247
        }
248
        this.processErrorHandlersRegistered = false;
30✔
249
    }
250

251
    private onDeviceBreakpointsChanged(eventName: 'changed' | 'new', data: { breakpoints: AugmentedSourceBreakpoint[] }) {
252
        this.logger.info('Sending verified device breakpoints to client', data);
3✔
253
        //send all verified breakpoints to the client
254
        for (const breakpoint of data.breakpoints) {
3✔
255
            const event: DebugProtocol.Breakpoint = {
3✔
256
                line: breakpoint.line,
257
                column: breakpoint.column,
258
                verified: breakpoint.verified,
259
                id: breakpoint.id,
260
                reason: breakpoint.reason,
261
                message: breakpoint.message,
262
                source: {
263
                    path: breakpoint.srcPath
264
                }
265
            };
266
            this.sendEvent(new BreakpointEvent(eventName, event));
3✔
267
        }
268
    }
269

270
    public logger = logger.createLogger(`[session]`);
199✔
271

272
    private readonly isWindowsPlatform = process.platform.startsWith('win');
199✔
273

274
    /**
275
     * A sequence used to help identify log statements for requests
276
     */
277
    private idCounter = 1;
199✔
278

279
    public fileManager: FileManager;
280

281
    public projectManager: ProjectManager;
282

283
    public fileLoggingManager: FileLoggingManager;
284

285
    private processErrorHandlersRegistered = false;
199✔
286
    private isCrashed = false;
199✔
287
    private _uncaughtExceptionHandler: ((error: Error) => void) | undefined;
288
    private _unhandledRejectionHandler: ((reason: unknown) => void) | undefined;
289

290
    public breakpointManager: BreakpointManager;
291

292
    public locationManager: LocationManager;
293

294
    public sourceMapManager: SourceMapManager;
295

296
    //set imports as class properties so they can be spied upon during testing
297
    public rokuDeploy = rokuDeploy as unknown as RokuDeploy;
199✔
298

299
    private componentLibraryServer = new ComponentLibraryServer();
199✔
300

301
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
199✔
302
    /**
303
     * A promise that is resolved whenever the app has started running for the first time
304
     */
305
    private firstRunDeferred = defer<void>();
199✔
306

307
    /**
308
     * Resolved whenever we're finished copying all the files to staging for all projects
309
     */
310
    private stagingDefered = defer<void>();
199✔
311

312
    private evaluateRefIdLookup: Record<string, number> = {};
199✔
313
    private evaluateRefIdCounter = 1;
199✔
314

315
    private variables: Record<number, AugmentedVariable> = {};
199✔
316

317
    /**
318
     * Caches the device lookups performed while resolving completion requests. Variables don't change
319
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
320
     * `clearState` whenever the debugger resumes or steps.
321
     */
322
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
199✔
323

324
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
325

326
    private perfettoManager: PerfettoManager;
327

328
    private rendezvousTracker: RendezvousTracker;
329

330
    public tempVarPrefix = '__rokudebug__';
199✔
331

332
    /**
333
     * The progressId of the active launch progress bar, if any.
334
     * Cleared once the ProgressEndEvent is sent.
335
     */
336
    private launchProgressId: string | undefined;
337

338
    /**
339
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
340
     */
341
    private compileError: BSDebugDiagnostic;
342

343
    /**
344
     * A magic number to represent a fake thread that will be used for showing compile errors in the UI as if they were runtime crashes
345
     */
346
    private COMPILE_ERROR_THREAD_ID = 7_777;
199✔
347

348
    private get enableDebugProtocol() {
349
        return this.launchConfiguration?.enableDebugProtocol;
70!
350
    }
351

352
    /**
353
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
354
     */
355
    private get supportsPerfettoTracing() {
356
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
13✔
357
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
13!
358
    }
359

360
    /**
361
     * Get a promise that resolves when the roku adapter is ready to be used
362
     */
363
    private async getRokuAdapter() {
364
        await this.rokuAdapterDeferred.promise;
26✔
365
        await this.rokuAdapter.onReady();
26✔
366
        return this.rokuAdapter;
26✔
367
    }
368

369
    private launchConfiguration: LaunchConfiguration;
370
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
371

372
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
199✔
373

374
    /**
375
     * The 'initialize' request is the first request called by the frontend
376
     * to interrogate the features the debug adapter provides.
377
     */
378
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
379
        this.initRequestArgs = args;
2✔
380
        this.logger.log('initializeRequest');
2✔
381

382
        response.body ||= {};
2✔
383

384
        // This debug adapter implements the configurationDoneRequest.
385
        response.body.supportsConfigurationDoneRequest = true;
2✔
386

387
        // The debug adapter supports the 'restart' request. In this case a client should not implement 'restart' by terminating and relaunching the adapter but by calling the RestartRequest.
388
        response.body.supportsRestartRequest = true;
2✔
389

390
        // make VS Code to use 'evaluate' when hovering over source
391
        response.body.supportsEvaluateForHovers = true;
2✔
392

393
        //NOTE: `supportsConditionalBreakpoints` and `supportsHitConditionalBreakpoints` are
394
        //sent later in the post-connect CapabilitiesEvent once we know which adapter is in use
395
        //(telnet always supports them via stop-statement rewrites; debug protocol requires v3.1.0+).
396
        //VS Code reads these caps per-render in the BREAKPOINTS view, so CapabilitiesEvent updates
397
        //take effect immediately - unlike `exceptionBreakpointFilters` / `breakpointModes` which
398
        //must be in the initialize response.
399

400
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
401
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
402
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
403
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
404
        response.body.exceptionBreakpointFilters = [{
2✔
405
            filter: 'caught',
406
            supportsCondition: true,
407
            conditionDescription: '__brs_err__.rethrown = true',
408
            label: 'Caught Exceptions',
409
            description: `Breaks on all errors, even if they're caught later.`,
410
            default: false
411
        }, {
412
            filter: 'uncaught',
413
            supportsCondition: true,
414
            conditionDescription: '__brs_err__.rethrown = true',
415
            label: 'Uncaught Exceptions',
416
            description: 'Breaks only on errors that are not handled.',
417
            default: true
418
        }];
419

420
        response.body.supportsCompletionsRequest = true;
2✔
421
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
2✔
422

423
        this.sendResponse(response);
2✔
424

425
        //register the debug output log transport writer
426
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2✔
427
            this.sendEvent(
538✔
428
                new DebugServerLogOutputEvent(
429
                    message.logger.formatMessage(message, false)
430
                )
431
            );
432
        });
433

434
        this.logger.log('initializeRequest finished');
2✔
435
    }
436

437
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
438
        response.body ??= {};
9!
439
        try {
9✔
440

441
            let filterOptions: ExceptionBreakpoint[];
442
            if (args.filterOptions) {
9✔
443
                filterOptions = args.filterOptions.map(x => ({
2✔
444
                    filter: x.filterId as 'caught' | 'uncaught',
445
                    conditionExpression: x.condition
446
                }));
447
            } else if (args.filters) {
7✔
448
                filterOptions = args.filters.map(x => ({
9✔
449
                    filter: x as 'caught' | 'uncaught'
450
                }));
451
            }
452
            this.exceptionBreakpoints = filterOptions;
9✔
453

454
            //wait until the adapter object exists, but don't wait for the device to come online —
455
            //VS Code will not send configurationDone (and we cannot launch the channel) until we
456
            //respond to this request.
457
            await this.rokuAdapterDeferred.promise;
9✔
458

459
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
9✔
460
                //the adapter queues these filters internally if the debug protocol client hasn't
461
                //connected yet, and replays them once it does
462
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
8✔
463
                response.body.breakpoints = [
7✔
464
                    { verified: true },
465
                    { verified: true }
466
                ];
467
            } else {
468
                response.body.breakpoints = [
1✔
469
                    { verified: false },
470
                    { verified: false }
471
                ];
472
            }
473
        } catch (e) {
474
            //if error (or not supported)
475
            response.body.breakpoints = [
1✔
476
                { verified: false },
477
                { verified: false }
478
            ];
479
            this.logger.error('Failed to set exception breakpoints', e);
1✔
480
        } finally {
481
            this.sendResponse(response);
9✔
482
        }
483
    }
484

485

486
    protected async setTransientsToInvalid() {
487
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
×
488
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
×
489
            // Assigning the variable to the function call results in it becoming unintialized
490
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
×
491
        }
492
    }
493

494
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
4✔
495
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
4✔
496
        return response.selectedAction;
2✔
497
    }
498

499
    private static requestIdSequence = 0;
2✔
500

501
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
502
        const requestId = BrightScriptDebugSession.requestIdSequence++;
3✔
503
        const responsePromise = new Promise<R>((resolve, reject) => {
3✔
504
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
3✔
505
                if (response.requestId === requestId) {
1!
506
                    if (response.error) {
1!
507
                        throw response.error;
×
508
                    } else {
509
                        resolve(response as R);
1✔
510
                    }
511
                }
512
            });
513
        });
514
        this.sendEvent(
3✔
515
            new CustomRequestEvent({
516
                requestId: requestId,
517
                name: name,
518
                ...data ?? {}
9!
519
            }));
520
        return responsePromise;
3✔
521
    }
522

523
    /**
524
      * Get the cwd from the launchConfiguration, or default to process.cwd()
525
      */
526
    private get cwd() {
527
        return this.launchConfiguration?.cwd ?? process.cwd();
6!
528
    }
529

530
    public deviceInfo: DeviceInfo;
531

532
    /**
533
     * Set defaults and standardize values for all of the LaunchConfiguration values
534
     * @param config
535
     * @returns
536
     */
537
    private normalizeLaunchConfig(config: LaunchConfiguration) {
538
        config.cwd ??= process.cwd();
6✔
539
        config.outDir ??= s`${config.cwd}/out`;
6✔
540
        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
6!
541
        config.componentLibrariesPort ??= 8080;
6!
542
        config.packagePort ??= 80;
6!
543
        config.remotePort ??= 8060;
6!
544
        config.sceneGraphDebugCommandsPort ??= 8080;
6!
545
        config.controlPort ??= 8081;
6!
546
        config.brightScriptConsolePort ??= 8085;
6!
547
        config.stagingDir ??= config.stagingFolderPath;
6!
548
        config.emitChannelPublishedEvent ??= true;
6!
549
        config.rewriteDevicePathsInLogs ??= true;
6!
550
        config.autoResolveVirtualVariables ??= false;
6!
551
        config.enhanceREPLCompletions ??= true;
6!
552
        config.username ??= 'rokudev';
6!
553
        if (config.profiling?.tracing?.enable) {
6!
554
            config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
×
555
            // eslint-disable-next-line no-template-curly-in-string
556
            config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
×
557
        }
558

559
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
560
        if (typeof config.enableVariablesPanel !== 'boolean') {
6!
561
            config.enableVariablesPanel = true;
6✔
562
        }
563
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
6!
564
        return config;
6✔
565
    }
566

567
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
568
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
6✔
569

570
        try {
6✔
571
            this.resetSessionState();
6✔
572
            this.launchConfiguration = this.normalizeLaunchConfig(config);
6✔
573
            this.setupProcessErrorHandlers();
6✔
574

575
            //prebake some threads for our ProjectManager to use later on (1 for the main project, and 1 for every complib)
576
            bscProjectWorkerPool.preload(1 + (this.launchConfiguration?.componentLibraries?.length ?? 0));
6!
577

578
            //set the logLevel provided by the launch config
579
            if (this.launchConfiguration.logLevel) {
6!
580
                logger.logLevel = this.launchConfiguration.logLevel;
×
581
            }
582

583
            this.sendLaunchProgress('start', 'Finding device on network');
6✔
584

585
            //do a DNS lookup for the host to fix issues with roku rejecting ECP
586
            try {
6✔
587
                this.launchConfiguration.host = await util.dnsLookup(this.launchConfiguration.host);
6✔
588
            } catch (e) {
589
                return this.shutdown(`Could not resolve ip address for host '${this.launchConfiguration.host}'`);
×
590
            }
591

592
            // fetches the device info and parses the xml data to JSON object
593
            try {
6✔
594
                this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
6✔
595
                if (this.deviceInfo.ecpSettingMode === 'limited') {
6!
596
                    return await this.shutdown(`ECP access is limited on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
597
                }
598
            } catch (e) {
599
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
600
                    return this.shutdown(`ECP access is disabled on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
601
                }
602
                return this.shutdown(`Unable to connect to roku at '${this.launchConfiguration.host}'. Verify the IP address is correct and that the device is powered on and connected to same network as this computer.`);
×
603
            }
604

605
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
6!
606
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
×
607
            }
608

609
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
610
            this.sendResponse(response);
6✔
611

612
            //initialize all file logging (rokuDevice, debugger, etc)
613
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
6!
614

615
            this.projectManager.launchConfiguration = this.launchConfiguration;
6✔
616
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
6✔
617

618
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
6✔
619

620
            this.logger.log('[launchRequest] Packaging and deploying to roku');
6✔
621
            const packageEnd = this.logger.timeStart('log', 'Packaging');
6✔
622
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
6!
623
            //build the main project and all component libraries at the same time
624
            await Promise.all([
6✔
625
                this.prepareMainProject(),
626
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
627
            ]);
628

629
            //all of the projects have been successfully staged.
630
            this.stagingDefered.tryResolve();
6✔
631

632
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
633
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
6!
634
                await this.sendCustomRequest('processStagingDir', {
×
635
                    projects: this.projectManager.getProjectStagingInfo()
636
                });
637
            }
638

639
            packageEnd();
6✔
640

641
            if (this.enableDebugProtocol) {
6!
642
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.launchConfiguration.host}:${this.launchConfiguration.controlPort}`);
×
643
            } else {
644
                util.log(`Connecting to Roku via telnet at ${this.launchConfiguration.host}:${this.launchConfiguration.brightScriptConsolePort}`);
6✔
645
            }
646

647
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
648
            try {
6✔
649
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
6✔
650
                await this.initRendezvousTracking();
6✔
651
                rendezvousEnd();
6✔
652
            } catch (e) {
653
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
654
            }
655

656
            this.sendLaunchProgress('update', 'Connecting to debug server');
6✔
657
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
6✔
658
            this.createRokuAdapter(this.rendezvousTracker);
6✔
659
            await this.connectRokuAdapter();
6✔
660
            connectAdapterEnd();
6✔
661

662
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
663
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
664
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
665
            // effect dynamically.
666
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
6✔
667
            this.sendEvent(new CapabilitiesEvent({
6✔
668
                supportsLogPoints: !this.enableDebugProtocol,
669
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
670
                supportsExceptionOptions: supportsExceptionBreakpoints,
671
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
672
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
673
            }));
674

675
            this.sendLaunchProgress('update', 'Configuring breakpoints');
6✔
676

677
            util.log('Done initializing');
6✔
678

679
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
680
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
681
            this.sendEvent(new InitializedEvent());
6✔
682

683
            await this.initializeProfiling();
6✔
684

685
        } catch (e) {
686
            //if the message is anything other than compile errors, we want to display the error
687
            if (!(e instanceof CompileError)) {
×
688
                util.log('Encountered an issue during the launch process');
×
689
                util.log((e as Error)?.stack);
×
690

691
                //send any compile errors to the client
692
                await this.rokuAdapter?.sendErrors();
×
693

694
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
695
                await this.shutdown(message as string, true);
×
696
            } else {
697
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
698
            }
699
        }
700
        logEnd();
6✔
701
    }
702

703
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
704
        this.logger.log('configurationDoneRequest');
3✔
705
        super.configurationDoneRequest(response, args);
3✔
706

707
        let error: Error;
708
        try {
3✔
709
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
3✔
710

711
            //press the home button to ensure we're at the home screen
712
            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
3✔
713

714
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
715
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
3✔
716

717
            //pass along the console output
718
            if (this.launchConfiguration.consoleOutput === 'full') {
3!
719
                this.rokuAdapter.on('console-output', (data) => {
×
720
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
721
                });
722
            } else {
723
                this.rokuAdapter.on('unhandled-console-output', (data) => {
3✔
724
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
725
                });
726
            }
727

728
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
3✔
729
                const stopDebuggerAction = 'Stop Debugger';
×
730
                const message = `Roku device ${this.launchConfiguration.host} is not responding and may not recover.` +
×
731
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
732
                this.logger.log(message, data);
×
733
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
734
                if (response === stopDebuggerAction) {
×
735
                    await this.shutdown();
×
736
                }
737
            });
738

739
            // Send chanperf events to the extension
740
            this.rokuAdapter.on('chanperf', (output) => {
3✔
741
                this.sendEvent(new ChanperfEvent(output));
×
742
            });
743

744
            //listen for a closed connection (shut down when received)
745
            this.rokuAdapter.on('close', (reason = '') => {
3!
746
                if (reason === 'compileErrors') {
×
747
                    error = new Error('compileErrors');
×
748
                } else {
749
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
750
                }
751
            });
752

753
            // handle any compile errors
754
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
3✔
755
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
756
            });
757

758
            // close disconnect if required when the app is exited
759
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
760
            this.rokuAdapter.on('app-exit', async () => {
3✔
761
                this.resetSessionState();
×
762

763
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
764
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
765
                    message += ' - shutting down debug session';
×
766

767
                    this.logger.log('on app-exit', message);
×
768
                    this.sendEvent(new LogOutputEvent(message));
×
769
                    await this.shutdown();
×
770
                } else {
771
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
772
                    this.logger.log('[configurationDoneRequest]', message);
×
773
                    this.sendEvent(new LogOutputEvent(message));
×
774
                    this.rokuAdapter.once('connected').then(async () => {
×
775
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
776
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
777
                }
778
            });
779
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
780
            await this.tryProfilingConnectOnStart();
3✔
781

782
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
783
            //that the client has finished sending configuration). Inject the STOPs and seal zips now.
784
            await Promise.all([
3✔
785
                this.packageMainProject(),
786
                this.packageAndHostComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
787
            ]);
788

789
            this.sendLaunchProgress('update', 'Uploading to Roku');
3✔
790
            await this.publish();
3✔
791

792
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
793
            if (this.launchConfiguration.emitChannelPublishedEvent) {
2!
794
                this.sendEvent(new ChannelPublishedEvent(
2✔
795
                    this.launchConfiguration
796
                ));
797
            }
798

799
            //tell the adapter adapter that the channel has been launched.
800
            this.sendLaunchProgress('update', 'Waiting on application');
2✔
801
            await this.rokuAdapter.activate();
2✔
802
            if (this.rokuAdapter.isDestroyed) {
2!
803
                throw new Error('Debug session encountered an error');
×
804
            }
805
            if (!error) {
2!
806
                if (this.rokuAdapter.connected) {
2!
807
                    this.logger.info('Host connection was established before the main public process was completed');
2✔
808
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
2✔
809
                } else {
810
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
811
                    this.rokuAdapter.on('connected', (status) => {
×
812
                        if (status) {
×
813
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
×
814
                        }
815
                    });
816
                }
817
            } else {
818
                throw error;
×
819
            }
820

821
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
822
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
2!
823
                //wait until the first entry breakpoint has been hit
824
                await this.firstRunDeferred.promise;
×
825
                //if we are at a breakpoint, continue
826
                await this.rokuAdapter.continue();
×
827
                //kill the app on the roku
828
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
829
                //convert a hostname to an ip address
830
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
831
                //send the deep link http request
832
                await util.httpPost(deepLinkUrl);
×
833
            }
834

835
        } catch (e) {
836
            //if the message is anything other than compile errors, we want to display the error
837
            if (!(e instanceof CompileError)) {
1!
838
                util.log('Encountered an issue during the publish process');
×
839
                util.log((e as Error)?.stack);
×
840

841
                //send any compile errors to the client
842
                await this.rokuAdapter?.sendErrors();
×
843

844
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
845
                await this.shutdown(message as string, true);
×
846
            } else {
847
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
848
            }
849
        }
850
    }
851

852
    /**
853
     * Activate all required functionality for profiling
854
     */
855
    private async initializeProfiling() {
856

857
        // Initialize PerfettoManager
858
        this.perfettoManager = new PerfettoManager({
15✔
859
            host: this.launchConfiguration.host,
860
            rootDir: this.launchConfiguration.rootDir,
861
            remotePort: this.launchConfiguration.remotePort,
862
            ...this.launchConfiguration.profiling?.tracing
45✔
863
        });
864

865
        //send certain profiling events back to the client
866
        this.perfettoManager.on('enable', (event) => {
15✔
867
            this.sendEvent(new ProfilingEnableEvent({
×
868
                types: event.types
869
            }));
870
        });
871
        this.perfettoManager.on('start', (event) => {
15✔
872
            this.sendEvent(new ProfilingStartEvent({
×
873
                type: event.type
874
            }));
875
        });
876
        this.perfettoManager.on('stop', (event) => {
15✔
877
            this.sendEvent(new ProfilingStopEvent({
×
878
                type: event.type,
879
                result: event.result
880
            }));
881
        });
882
        this.perfettoManager.on('error', (event) => {
15✔
883
            this.sendEvent(new ProfilingErrorEvent({
×
884
                error: event.error
885
            }));
886
        });
887

888
        //tracing is explicitly enabled. Turn it on
889
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
15✔
890
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2✔
891
            try {
2✔
892
                await this.perfettoManager.enableTracing();
2✔
893
            } catch (e) {
894
                this.logger.error('Failed to enable perfetto tracing', e);
1✔
895
            }
896

897
            //tracing is expicitly DISabled. turn it off
898
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
13✔
899
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
2✔
900
            //TODO implement a way to disable perfetto tracing on the device
901

902
            //tracing was requested but the device firmware does not meet the minimum requirement
903
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
11✔
904
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2!
905
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). The Perfetto profiling buttons will not be available during this session.`;
2✔
906
            this.logger.warn(message);
2✔
907
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
2✔
908

909
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
910
        } else {
911
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
9✔
912
        }
913
    }
914

915
    /**
916
     * If profiling was marked "connectOnStart", try connecting right away
917
     */
918
    private async tryProfilingConnectOnStart() {
919
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
8✔
920
            try {
2✔
921
                await this.perfettoManager.startTracing();
2✔
922
            } catch (e) {
923
                this.logger.error('Failed to start perfetto tracing on start', e);
1✔
924
            }
925
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
6✔
926
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1!
927
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1✔
928
            this.logger.warn(message);
1✔
929
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1✔
930
        }
931
    }
932

933
    /**
934
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
935
     */
936
    private resetSessionState() {
937
        // launchRequest gets invoked by our restart session flow.
938
        // We need to clear/reset some state to avoid issues.
939
        this.entryBreakpointWasHandled = false;
7✔
940
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
941
        //re-stages the project
942
        this.breakpointManager.reset();
7✔
943
    }
944

945
    /**
946
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
947
     */
948
    public async initRendezvousTracking() {
949
        const timeout = 5000;
3✔
950
        let initCompleted = false;
3✔
951
        await Promise.race([
3✔
952
            util.sleep(timeout),
953
            this._initRendezvousTracking().finally(() => {
954
                initCompleted = true;
3✔
955
            })
956
        ]);
957

958
        if (initCompleted === false) {
3!
959
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
×
960
                this.logger.error('Error showing popup message', { error });
×
961
            });
962
        }
963
    }
964

965
    private async _initRendezvousTracking() {
966
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
967

968
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
969
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3✔
970
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
971
        });
972

973
        // Send rendezvous events to the debug protocol client
974
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
975
            this.sendEvent(new RendezvousEvent(output));
1✔
976
        });
977

978
        //clear the history so the user doesn't have leftover rendezvous data from a previous session
979
        this.rendezvousTracker.clearHistory();
3✔
980

981
        //if rendezvous tracking is enabled, then enable it on the device
982
        if (this.launchConfiguration.rendezvousTracking !== false) {
3✔
983
            // start ECP rendezvous tracking (if possible)
984
            await this.rendezvousTracker.activate();
2✔
985
        }
986
    }
987

988
    /**
989
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
990
     */
991
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
992
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
993
        for (let diagnostic of diagnostics) {
2✔
994
            diagnostic.source = diagnosticSource;
2✔
995
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
996
            if (sourceLocation) {
2✔
997
                diagnostic.path = sourceLocation.filePath;
1✔
998
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
999
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1000
            } else {
1001
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1002
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
1003
            }
1004
        }
1005

1006
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1007
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2✔
1008
        if (this.compileError) {
2✔
1009
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1010
            this.sendEvent(new StoppedEvent(
1✔
1011
                StoppedEventReason.exception,
1012
                this.COMPILE_ERROR_THREAD_ID,
1013
                `CompileError: ${this.compileError.message}`
1014
            ));
1015
        }
1016

1017
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1018
    }
1019

1020
    private publishTimeout = 60_000;
199✔
1021

1022
    private async publish() {
1023
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2✔
1024
        let packageIsPublished = false;
2✔
1025

1026
        //delete any currently installed dev channel (if enabled to do so)
1027
        try {
2✔
1028
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
2!
1029
                await this.rokuDeploy.deleteInstalledChannel({
×
1030
                    ...this.launchConfiguration
1031
                } as any as RokuDeployOptions);
1032
            }
1033
        } catch (e) {
1034
            const statusCode = e?.results?.response?.statusCode;
×
1035
            const message = e.message as string;
×
1036
            if (statusCode === 401) {
×
1037
                await this.shutdown(message, true);
×
1038
                throw e;
×
1039
            }
1040
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1041
        }
1042

1043
        const isConnected = this.rokuAdapter.once('app-ready');
2✔
1044
        const options: RokuDeployOptions = {
2✔
1045
            ...this.launchConfiguration,
1046
            //typing fix
1047
            logLevel: LogLevelPriority[this.logger.logLevel],
1048
            // enable the debug protocol if true
1049
            remoteDebug: this.enableDebugProtocol,
1050
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1051
            remoteDebugConnectEarly: false,
1052
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1053
            failOnCompileError: true,
1054
            //pass any upload form overrides the client may have configured
1055
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1056
        };
1057
        //if packagePath is specified, use that info instead of outDir and outFile
1058
        if (this.launchConfiguration.packagePath) {
2✔
1059
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1060
            options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1061
        }
1062

1063
        //publish the package to the target Roku
1064
        const publishPromise = this.rokuDeploy.publish(options).then(() => {
2✔
1065
            packageIsPublished = true;
2✔
1066
        }).catch(async (e) => {
1067
            const statusCode = e?.results?.response?.statusCode;
×
1068
            const message = e.message as string;
×
1069
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
1070
                await this.shutdown(message, true);
×
1071
                throw e;
×
1072
            }
1073
            this.logger.error(e);
×
1074
        });
1075

1076
        await publishPromise;
2✔
1077

1078
        uploadingEnd();
2✔
1079

1080
        //the channel has been deployed. Wait for the adapter to finish connecting.
1081
        //if it hasn't connected after 60 seconds, abort the launch.
1082
        let didTimeOut = false;
2✔
1083
        await Promise.race([
2✔
1084
            isConnected,
1085
            util.sleep(this.publishTimeout).then(() => {
1086
                didTimeOut = true;
2✔
1087
            })
1088
        ]);
1089
        this.logger.log('Finished racing promises');
2✔
1090
        if (didTimeOut) {
2✔
1091
            this.logger.warn('Timed out waiting for roku to connect');
1✔
1092
        }
1093
        //if the adapter is still not connected, then it will probably never connect. Abort.
1094
        if (packageIsPublished && !this.rokuAdapter.connected) {
2✔
1095
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1✔
1096
        }
1097
    }
1098

1099
    private pendingSendLogPromise = Promise.resolve();
199✔
1100

1101
    /**
1102
     * Send log output to the "client" (i.e. vscode)
1103
     * @param logOutput
1104
     */
1105
    private sendLogOutput(logOutput: string) {
1106
        if (this.isCrashed) {
38!
1107
            return Promise.resolve();
×
1108
        }
1109
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1110

1111
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1112
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1113

1114
            const lines = logOutput.split(/\r?\n/g);
38✔
1115
            for (let i = 0; i < lines.length; i++) {
38✔
1116
                let line = lines[i];
264✔
1117
                if (i < lines.length - 1) {
264✔
1118
                    line += '\n';
226✔
1119
                }
1120

1121
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
264✔
1122
                    let potentialPaths = this.getPotentialPkgPaths(line);
91✔
1123
                    for (let potentialPath of potentialPaths) {
91✔
1124
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28✔
1125
                        if (originalLocation) {
28✔
1126
                            let replacement: string;
1127
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26✔
1128
                            if (replacement !== originalLocation.filePath) {
26✔
1129
                                if (this.isWindowsPlatform) {
6✔
1130
                                    replacement = `vscode://file/${replacement}`;
3✔
1131
                                } else {
1132
                                    replacement = `file://${replacement}`;
3✔
1133
                                }
1134
                            }
1135
                            replacement += `:${originalLocation.lineNumber}`;
26✔
1136
                            if (potentialPath.columnNumber !== undefined) {
26✔
1137
                                replacement += `:${originalLocation.columnIndex + 1}`;
10✔
1138
                            }
1139

1140
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1141
                        }
1142
                    }
1143
                }
1144
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1145
                this.sendEvent(new LogOutputEvent(line));
264✔
1146
            }
1147
        });
1148
        return this.pendingSendLogPromise;
38✔
1149
    }
1150

1151
    /**
1152
     * Extracts potential package paths from a given line of text.
1153
     *
1154
     * This method uses a regular expression to find matches in the provided line
1155
     * and returns an array of objects containing details about each match.
1156
     *
1157
     * @param input - The line of text to search for potential package paths.
1158
     * @returns An array of objects, each containing:
1159
     *   - `fullMatch`: The full matched string.
1160
     *   - `path`: The extracted path from the match.
1161
     *   - `lineNumber`: The line number extracted from the match.
1162
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1163
     */
1164
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1165
        // https://regex101.com/r/ixpQiq/1
1166
        let matches = input.matchAll(/((?:\.\.\.|[A-Za-z_0-9]*pkg\:\/)[A-Za-z_0-9 \/\.]+\.[A-Za-z_0-9 \/]+)(?:(?:\:)(\d+)(?:\:(\d+))?|\((\d+)(?:\:(\d+))?\))/ig);
91✔
1167
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1168
        if (matches) {
91!
1169
            for (let match of matches) {
91✔
1170
                let fullMatch = match[0];
28✔
1171
                let path = match[1];
28✔
1172
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1173
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1174
                if (isNaN(columnNumber)) {
28✔
1175
                    columnNumber = undefined;
17✔
1176
                }
1177
                paths.push({
28✔
1178
                    fullMatch: fullMatch,
1179
                    path: path,
1180
                    lineNumber: lineNumber,
1181
                    columnNumber: columnNumber
1182
                });
1183
            }
1184
        }
1185
        return paths;
91✔
1186
    }
1187

1188
    /**
1189
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1190
     */
1191
    private async convertBacktracePaths(input: string) {
1192
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1193
            return input;
8✔
1194
        }
1195
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1196
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1197

1198
        // https://regex101.com/r/y1koaV/2
1199
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30✔
1200
        let matches = [];
30✔
1201
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1202
        while (match) {
30✔
1203
            matches.push(match);
5✔
1204
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1205
        }
1206

1207
        if (matches) {
30!
1208
            for (let match of matches) {
30✔
1209
                let fullMatch = match[0] as string;
5✔
1210
                let filePath = match[1] as string;
5✔
1211
                let fullLineNumber = match[2] as string;
5✔
1212
                let lineNumber = parseInt(match[3] as string);
5✔
1213
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5✔
1214
                if (originalLocation) {
5✔
1215
                    let fileReplacement: string;
1216
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4✔
1217
                    if (fileReplacement !== originalLocation.filePath) {
4✔
1218
                        if (this.isWindowsPlatform) {
2✔
1219
                            fileReplacement = `vscode://file/${fileReplacement}`;
1✔
1220
                        } else {
1221
                            fileReplacement = `file://${fileReplacement}`;
1✔
1222
                        }
1223
                    }
1224
                    fileReplacement += `:${originalLocation.lineNumber}`;
4✔
1225

1226
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1227

1228
                    // replace the full backtrace object with the an updated version so we don't modify other parts of the log output that might contain the same file path
1229
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1230
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1231
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1232
                }
1233

1234
            }
1235
        }
1236

1237
        return input;
30✔
1238
    }
1239

1240
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1241
        if (commands) {
1!
1242
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1243

1244
            try {
×
1245
                await connection.connect();
×
1246
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1247
                    let response: SceneGraphCommandResponse;
1248
                    switch (command) {
×
1249
                        case 'chanperf':
1250
                            util.log('Enabling Chanperf Tracking');
×
1251
                            response = await connection.chanperf({ interval: 1 });
×
1252
                            if (!response.error) {
×
1253
                                util.log(response.result.rawResponse);
×
1254
                            }
1255
                            break;
×
1256

1257
                        case 'fpsdisplay':
1258
                            util.log('Enabling FPS Display');
×
1259
                            response = await connection.fpsDisplay('on');
×
1260
                            if (!response.error) {
×
1261
                                util.log(response.result.data as string);
×
1262
                            }
1263
                            break;
×
1264

1265
                        case 'logrendezvous':
1266
                            util.log('Enabling Rendezvous Logging:');
×
1267
                            response = await connection.logrendezvous('on');
×
1268
                            if (!response.error) {
×
1269
                                util.log(response.result.rawResponse);
×
1270
                            }
1271
                            break;
×
1272

1273
                        default:
1274
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
1275
                            response = await connection.exec(command);
×
1276
                            if (!response.error) {
×
1277
                                util.log(response.result.rawResponse);
×
1278
                            }
1279
                            break;
×
1280
                    }
1281
                }
1282
                await connection.end();
×
1283
            } catch (error) {
1284
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1285
            }
1286
        }
1287
    }
1288

1289
    /**
1290
     * Stage, insert breakpoints, and package the main project
1291
     */
1292
    public async prepareMainProject() {
1293
        //add the main project
1294
        this.projectManager.mainProject = new Project({
2✔
1295
            rootDir: this.launchConfiguration.rootDir,
1296
            files: this.launchConfiguration.files,
1297
            outDir: this.launchConfiguration.outDir,
1298
            sourceDirs: this.launchConfiguration.sourceDirs,
1299
            bsConst: this.launchConfiguration.bsConst,
1300
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1301
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1302
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1303
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1304
            stagingDir: this.launchConfiguration.stagingDir,
1305
            packagePath: this.launchConfiguration.packagePath,
1306
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1307
        });
1308

1309
        util.log('Moving selected files to staging area');
2✔
1310
        await this.projectManager.mainProject.stage();
2✔
1311

1312
        //add the entry breakpoint if stopOnEntry is true
1313
        await this.handleEntryBreakpoint();
2✔
1314
    }
1315

1316
    /**
1317
     * Inject breakpoint STOP statements into the staged main project and create the zip package.
1318
     * Runs after the DAP `InitializedEvent` so client-side `setBreakpoints` requests have landed
1319
     * before any STOPs are written to the staged .brs files (telnet path) and before the zip is sealed.
1320
     */
1321
    private async packageMainProject() {
1322
        //add breakpoint lines to source files and then publish
1323
        util.log('Adding stop statements for active breakpoints');
2✔
1324

1325
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1326
        await this.breakpointManager.validateAndWriteBreakpointsForProject(this.projectManager.mainProject);
2✔
1327

1328
        if (this.launchConfiguration.packageTask) {
2✔
1329
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1330
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1331

1332
            const options = {
1✔
1333
                ...this.launchConfiguration
1334
            } as any as RokuDeployOptions;
1335
            //if packagePath is specified, use that info instead of outDir and outFile
1336
            if (this.launchConfiguration.packagePath) {
1!
1337
                options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1338
                options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1339
            }
1340
            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
1!
1341

1342
            if (!fsExtra.pathExistsSync(packagePath as string)) {
1!
1343
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1344
            }
1345
        } else {
1346
            //create zip package from staging folder
1347
            util.log('Creating zip archive from project sources');
1✔
1348
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1✔
1349
        }
1350
    }
1351

1352
    /**
1353
     * Accepts custom events and requests from the extension
1354
     * @param command name of the command to execute
1355
     */
1356
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1357
        if (command === 'rendezvous.clearHistory') {
×
1358
            this.rokuAdapter.clearRendezvousHistory();
×
1359
        } else if (command === 'chanperf.clearHistory') {
×
1360
            this.rokuAdapter.clearChanperfHistory();
×
1361

1362
        } else if (command === 'customRequestEventResponse') {
×
1363
            this.emit('customRequestEventResponse', args);
×
1364

1365
        } else if (command === 'popupMessageEventResponse') {
×
1366
            this.emit('popupMessageEventResponse', args);
×
1367

1368
        } else if (command === 'captureHeapSnapshot') {
×
1369
            this.perfettoManager.captureHeapSnapshot().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
×
1370

1371
        } else if (command === 'startPerfettoTracing') {
×
1372
            try {
×
1373
                await this.perfettoManager.startTracing();
×
1374
            } catch (e) {
1375
                response.success = false;
×
1376
                response.body = { message: e?.message || String(e) };
×
1377
            }
1378

1379
        } else if (command === 'stopPerfettoTracing') {
×
1380
            try {
×
1381
                await this.perfettoManager.stopTracing();
×
1382
            } catch (e) {
1383
                response.success = false;
×
1384
                response.body = { message: e?.message || String(e) };
×
1385
            }
1386

1387
        }
1388
        this.sendResponse(response);
×
1389
    }
1390

1391
    /**
1392
     * Stores the path to the staging folder for each component library
1393
     */
1394
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1395
        if (!componentLibraries || componentLibraries.length === 0) {
11✔
1396
            return;
2✔
1397
        }
1398
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1399
        //make sure this folder exists (and is empty)
1400
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1401
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1402

1403
        //create a ComponentLibraryProject for each component library
1404
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1405
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1406

1407
            this.projectManager.componentLibraryProjects.push(
19✔
1408
                new ComponentLibraryProject({
1409
                    rootDir: componentLibrary.rootDir,
1410
                    files: componentLibrary.files,
1411
                    outDir: componentLibrariesOutDir,
1412
                    outFile: componentLibrary.outFile,
1413
                    sourceDirs: componentLibrary.sourceDirs,
1414
                    bsConst: componentLibrary.bsConst,
1415
                    install: componentLibrary.install,
1416
                    enablePostfix: componentLibrary.enablePostfix,
1417
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1418
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1419
                    libraryIndex: libraryIndex,
1420
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1421
                })
1422
            );
1423
        }
1424

1425
        //stage all of the libraries in parallel
1426
        await Promise.all(
9✔
1427
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1428
        );
1429
    }
1430

1431
    /**
1432
     * Inject breakpoint STOPs into the staged complibs, seal their zips, install installable ones,
1433
     * and start static file hosting. Runs in `configurationDoneRequest` (after `InitializedEvent`)
1434
     * so client-side `setBreakpoints` requests have landed before the staged .brs files are written
1435
     * and the zips are sealed.
1436
     */
1437
    protected async packageAndHostComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1438
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1439
            return;
2✔
1440
        }
1441
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1442

1443
        // Add breakpoint lines to the staging files and before publishing
1444
        util.log('Adding stop statements for active breakpoints in Component Libraries');
8✔
1445

1446
        //validate breakpoints (and write STOPs for telnet), postfix, and zip each complib in parallel — each targets distinct files
1447
        const packagePromises = this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
8✔
1448
            await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
18✔
1449
            await compLibProject.postfixFiles();
18✔
1450
            await compLibProject.zipPackage({ retainStagingFolder: true });
18✔
1451
        });
1452

1453
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1454
        if (needToDeleteComplibs) {
8✔
1455
            await rokuDeploy.deleteAllComponentLibraries({
7✔
1456
                host: this.launchConfiguration.host,
1457
                password: this.launchConfiguration.password,
1458
                username: this.launchConfiguration.username || 'rokudev'
14✔
1459
            });
1460
        }
1461

1462
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
8✔
1463
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1464

1465
            if (compLibProject.install === true) {
18✔
1466
                //wait for this complib to finish being packaged
1467
                await packagePromises[i];
12✔
1468

1469
                if (componentLibraries[i].packageTask) {
12✔
1470
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1471
                }
1472

1473
                const options: RokuDeployOptions = {
12✔
1474
                    host: this.launchConfiguration.host,
1475
                    password: this.launchConfiguration.password,
1476
                    username: this.launchConfiguration.username || 'rokudev',
24✔
1477
                    logLevel: LogLevelPriority[this.logger.logLevel],
1478
                    failOnCompileError: true,
1479
                    outDir: compLibProject.outDir,
1480
                    outFile: compLibProject.outFile,
1481
                    appType: 'dcl',
1482
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22✔
1483
                };
1484

1485
                if (componentLibraries[i].packagePath) {
12✔
1486
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1487
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1488
                }
1489

1490
                try {
12✔
1491
                    await rokuDeploy.publish(options);
12✔
1492
                } catch (error) {
1493
                    this.logger.error(`Error installing component library ${i}`, error);
4✔
1494
                }
1495
            }
1496
        }
1497

1498
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
8✔
1499
            util.log(message);
×
1500
        });
1501

1502
        //wait for all complib packaging to finish and the file hosting to start
1503
        await Promise.all([
8✔
1504
            ...packagePromises,
1505
            hostingPromise
1506
        ]);
1507
    }
1508

1509
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
1510
        this.logger.log('sourceRequest');
×
1511
        let old = this.sendResponse;
×
1512
        this.sendResponse = function sendResponse(...args) {
×
1513
            old.apply(this, args);
×
1514
            this.sendResponse = old;
×
1515
        };
1516
        super.sourceRequest(response, args);
×
1517
    }
1518

1519
    /**
1520
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1521
     */
1522
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1523
        this.logger.log('setBreakpointsRequest', args);
6✔
1524
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1525
        //sort the breakpoints
1526
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1527

1528
        response.body = {
6✔
1529
            breakpoints: sortedAndFilteredBreakpoints
1530
        };
1531
        this.sendResponse(response);
6✔
1532

1533
        //ensure we've staged all the files
1534
        await this.stagingDefered.promise;
6✔
1535

1536
        await this.rokuAdapter?.syncBreakpoints();
6!
1537
    }
1538

1539
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1540
        this.logger.log('exceptionInfoRequest');
×
1541
    }
1542

1543
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1544
        this.logger.log('threadsRequest');
4✔
1545

1546
        let threads = [];
4✔
1547

1548
        //This is a bit of a hack. If there's a compile error, send a thread to represent it so we can show the compile error like a runtime exception
1549
        if (this.compileError) {
4!
1550
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1551
        } else {
1552
            //wait for the roku adapter to load
1553
            await this.getRokuAdapter();
4✔
1554

1555
            //only send the threads request if we are at the debugger prompt
1556
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1557
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1558

1559
                for (let thread of rokuThreads) {
3✔
1560
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1561
                    threads.push(
4✔
1562
                        new Thread(thread.threadId, threadName)
1563
                    );
1564
                }
1565

1566
                if (threads.length === 0) {
3!
1567
                    threads = [{
×
1568
                        id: 1001,
1569
                        name: 'unable to retrieve threads: not stopped',
1570
                        isFake: true
1571
                    }];
1572
                }
1573

1574
            } else {
1575
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1576
            }
1577

1578
        }
1579

1580
        response.body = {
4✔
1581
            threads: threads
1582
        };
1583

1584
        this.sendResponse(response);
4✔
1585
    }
1586

1587
    /**
1588
     * Get the thread name to display in the UI based on the thread info we have.
1589
     * This is what displays in the `call stack` region in vscode
1590
     * @param thread
1591
     * @returns
1592
     */
1593
    private getThreadName(thread: AdapterThread) {
1594
        let threadName = '';
24✔
1595
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1596
            //build the name from only the parts that are present, so missing values don't leak into the name
1597
            const parts: string[] = [];
13✔
1598
            if (thread.type) {
13✔
1599
                parts.push(`[${thread.type}]`);
10✔
1600
            }
1601
            if (thread.name) {
13✔
1602
                parts.push(thread.name);
9✔
1603
            }
1604
            if (thread.osThreadId) {
13✔
1605
                parts.push(thread.osThreadId);
9✔
1606
            }
1607
            threadName = parts.join(' ');
13✔
1608
        }
1609
        //remove any extraneous whitespace to deal with missing values
1610
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1611

1612
        if (threadName === '') {
24✔
1613
            threadName = `Thread ${thread.threadId}`;
11✔
1614
        }
1615

1616
        if (thread.isDetached) {
24✔
1617
            threadName += ' [detached]';
4✔
1618
        }
1619

1620
        //remove any extraneous whitespace to deal with missing values
1621
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1622

1623
        return threadName;
24✔
1624
    }
1625

1626
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1627
        try {
3✔
1628
            this.logger.log('stackTraceRequest');
3✔
1629
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1630

1631
            //this is a bit of a hack. If there's a compile error, send a full stack frame so we can show the compile error like a runtime crash
1632
            if (this.compileError) {
3!
1633
                frames.push(new StackFrame(
×
1634
                    0,
1635
                    'Compile Error',
1636
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1637
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1638
                    this.toClientLine(this.compileError.range.start.line),
1639
                    this.toClientColumn(this.compileError.range.start.character)
1640
                ));
1641
            } else if (args.threadId === 1001) {
3!
1642
                frames.push(new StackFrame(
×
1643
                    0,
1644
                    'ERROR: threads would not stop',
1645
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1646
                    this.toClientLine(0),
1647
                    this.toClientColumn(0)
1648
                ));
1649
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1650
                    this.logger.error('Error showing popup message', { error });
×
1651
                });
1652
            } else {
1653
                //ensure the rokuAdapter is loaded
1654
                await this.getRokuAdapter();
3✔
1655

1656
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1657
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1658
                    if (stackTrace.length === 0) {
3✔
1659
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1660
                        // the thread without letting the user navigate to a source location
1661
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1662
                        frame.presentationHint = 'label';
2✔
1663
                        frames.push(frame);
2✔
1664
                    } else {
1665
                        for (let debugFrame of stackTrace) {
1✔
1666
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1667

1668
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1669
                            //load the contents of the file and get the correct casing for the function identifier
1670
                            try {
3✔
1671
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1672
                                if (functionName) {
3!
1673

1674
                                    //search for original function name if this is an anonymous function.
1675
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1676
                                    if (functionName.startsWith('$')) {
3!
1677
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1678
                                            sourceLocation.filePath,
1679
                                            sourceLocation.lineNumber - 1,
1680
                                            functionName
1681
                                        );
1682
                                    }
1683
                                    debugFrame.functionIdentifier = functionName;
3✔
1684
                                }
1685
                            } catch (error) {
1686
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1687
                            }
1688
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1689

1690
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1691
                                debugFrame.frameId,
1692
                                `${debugFrame.functionIdentifier}`,
1693
                                new Source(path.basename(filePath), filePath),
1694
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1695
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1696
                                this.toClientColumn(0)
1697
                            );
1698
                            if (!sourceLocation) {
3!
1699
                                frame.presentationHint = 'subtle';
×
1700
                            }
1701
                            frames.push(frame);
3✔
1702
                        }
1703
                    }
1704
                } else {
1705
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1706
                }
1707
            }
1708
            response.body = {
3✔
1709
                stackFrames: frames,
1710
                totalFrames: frames.length
1711
            };
1712
            this.sendResponse(response);
3✔
1713
        } catch (error) {
1714
            this.logger.error('Error getting stacktrace', { error, args });
×
1715
        }
1716
    }
1717

1718
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1719
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1720
        logger.info('begin', { args });
1✔
1721
        try {
1✔
1722
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1723

1724
            // create the locals scope
1725
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1726

1727
            let localScope: DebugProtocol.Scope = {
1✔
1728
                name: 'Local',
1729
                variablesReference: v.variablesReference,
1730
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1731
                expensive: this.launchConfiguration.deferScopeLoading,
1732
                presentationHint: 'locals'
1733
            };
1734

1735
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
1736
            if (frame) {
×
1737
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1738

1739
                if (scopeRange) {
×
1740
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1741
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
1742
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
1743
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1744
                }
1745
            }
1746

1747
            scopes.push(localScope);
×
1748

1749
            // create the registry scope
1750
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
1751
            scopes.push(<DebugProtocol.Scope>{
×
1752
                name: 'Registry',
1753
                variablesReference: registryRefId,
1754
                expensive: true
1755
            });
1756

1757
            this.variables[registryRefId] = {
×
1758
                variablesReference: registryRefId,
1759
                name: 'Registry',
1760
                value: '',
1761
                type: '$$Registry',
1762
                isScope: true,
1763
                childVariables: []
1764
            };
1765

1766
            response.body = {
×
1767
                scopes: scopes
1768
            };
1769
            logger.debug('send response', { response });
×
1770
            this.sendResponse(response);
×
1771
            logger.info('end');
×
1772
        } catch (error) {
1773
            logger.error('Error getting scopes', { error, args });
1✔
1774
        }
1775
    }
1776

1777
    /**
1778
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
1779
     * The child variables are filled in lazily by `populateScopeVariables`.
1780
     */
1781
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
1782
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
1783
        if (!this.variables[refId]) {
5✔
1784
            this.variables[refId] = {
1✔
1785
                variablesReference: refId,
1786
                name: 'Locals',
1787
                value: '',
1788
                type: '$$Locals',
1789
                frameId: frameId,
1790
                isScope: true,
1791
                childVariables: []
1792
            };
1793
        }
1794
        return this.variables[refId];
5✔
1795
    }
1796

1797
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
1798
        //if we have a compile error, we should shut down
1799
        if (this.compileError) {
×
1800
            this.sendResponse(response);
×
1801
            await this.shutdown();
×
1802
            return;
×
1803
        }
1804

1805
        this.logger.log('continueRequest');
×
1806
        await this.setTransientsToInvalid(); // call before clearState
×
1807
        this.clearState();
×
1808

1809
        // The debug session ends after the next line. Do not put new work after this line.
1810
        await this.rokuAdapter.continue();
×
1811
        this.sendResponse(response);
×
1812
    }
1813

1814
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
1815
        this.logger.log('pauseRequest');
×
1816

1817
        //if we have a compile error, we should shut down
1818
        if (this.compileError) {
×
1819
            this.sendResponse(response);
×
1820
            await this.shutdown();
×
1821
            return;
×
1822
        }
1823

1824
        await this.rokuAdapter.pause();
×
1825
        this.sendResponse(response);
×
1826
    }
1827

1828
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
1829
        this.logger.log('reverseContinueRequest');
×
1830
        this.sendResponse(response);
×
1831
    }
1832

1833
    /**
1834
     * Clicked the "Step Over" button
1835
     * @param response
1836
     * @param args
1837
     */
1838
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
1839
        this.logger.log('[nextRequest] begin');
×
1840

1841
        //if we have a compile error, we should shut down
1842
        if (this.compileError) {
×
1843
            this.sendResponse(response);
×
1844
            await this.shutdown();
×
1845
            return;
×
1846
        }
1847

1848
        await this.setTransientsToInvalid(); // call before clearState
×
1849
        this.clearState();
×
1850

1851
        // The debug session ends after the next line. Do not put new work after this line.
1852
        try {
×
1853
            await this.rokuAdapter.stepOver(args.threadId);
×
1854
            this.logger.info('[nextRequest] end');
×
1855
        } catch (error) {
1856
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
1857
        }
1858
        this.sendResponse(response);
×
1859
    }
1860

1861
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
1862
        this.logger.log('[stepInRequest]');
×
1863

1864
        //if we have a compile error, we should shut down
1865
        if (this.compileError) {
×
1866
            this.sendResponse(response);
×
1867
            await this.shutdown();
×
1868
            return;
×
1869
        }
1870

1871
        await this.setTransientsToInvalid(); // call before clearState
×
1872
        this.clearState();
×
1873
        // The debug session ends after the next line. Do not put new work after this line.
1874
        await this.rokuAdapter.stepInto(args.threadId);
×
1875
        this.sendResponse(response);
×
1876
        this.logger.info('[stepInRequest] end');
×
1877
    }
1878

1879
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
1880
        this.logger.log('[stepOutRequest] begin');
×
1881

1882
        //if we have a compile error, we should shut down
1883
        if (this.compileError) {
×
1884
            this.sendResponse(response);
×
1885
            await this.shutdown();
×
1886
            return;
×
1887
        }
1888

1889
        await this.setTransientsToInvalid(); // call before clearState
×
1890
        this.clearState();
×
1891

1892
        // The debug session ends after the next line. Do not put new work after this line.
1893
        await this.rokuAdapter.stepOut(args.threadId);
×
1894
        this.sendResponse(response);
×
1895
        this.logger.info('[stepOutRequest] end');
×
1896
    }
1897

1898
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
1899
        this.logger.log('[stepBackRequest] begin');
×
1900
        this.sendResponse(response);
×
1901
        this.logger.info('[stepBackRequest] end');
×
1902
    }
1903

1904
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
1905
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
1906
        let sendInvalidatedEvent = false;
4✔
1907
        let frameId: number = null;
4✔
1908
        try {
4✔
1909
            logger.log('begin', { args });
4✔
1910

1911
            //ensure the rokuAdapter is loaded
1912
            await this.getRokuAdapter();
4✔
1913

1914
            let updatedVariables: AugmentedVariable[] = [];
4✔
1915
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
1916
            await this.evaluateRequestPromise;
4✔
1917
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
1918
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
1919
                response.success = false;
×
1920
                response.message = 'Debug session is not paused';
×
1921
                return this.sendResponse(response);
×
1922
            }
1923

1924
            //find the variable with this reference
1925
            let v = this.variables[args.variablesReference];
4✔
1926
            if (!v) {
4!
1927
                response.success = false;
×
1928
                response.message = `Variable reference has expired`;
×
1929
                return this.sendResponse(response);
×
1930
            }
1931
            logger.log('variable', v);
4✔
1932

1933
            // Populate scope level values if needed
1934
            if (v.isScope) {
4✔
1935
                await this.populateScopeVariables(v, args);
2✔
1936
            }
1937

1938
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
1939
            if (v.childVariables.length === 0 || v.isResolved) {
4!
1940
                let tempVar: AugmentedVariable;
1941
                if (!v.isResolved) {
×
1942
                    // Evaluate the variable
1943
                    try {
×
1944
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
1945
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
1946
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
1947
                        tempVar.frameId = v.frameId;
×
1948
                        // Determine if the variable has changed
1949
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
1950
                    } catch (error) {
1951
                        logger.error('Error getting variables', error);
×
1952
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
1953
                        tempVar.type = '';
×
1954
                        tempVar.childVariables = [];
×
1955
                        sendInvalidatedEvent = true;
×
1956
                        response.success = false;
×
1957
                        response.message = error.message;
×
1958
                    }
1959

1960
                    // Merge the resulting updates together
1961
                    v.childVariables = tempVar.childVariables;
×
1962
                    v.value = tempVar.value;
×
1963
                    v.type = tempVar.type;
×
1964
                    v.indexedVariables = tempVar.indexedVariables;
×
1965
                    v.namedVariables = tempVar.namedVariables;
×
1966
                }
1967
                frameId = v.frameId;
×
1968

1969
                if (v?.presentationHint?.lazy || v.isResolved) {
×
1970
                    // If this was a lazy variable we need to respond with the updated variable and not the children
1971
                    if (v.isResolved && v.childVariables.length > 0) {
×
1972
                        updatedVariables = v.childVariables;
×
1973
                    } else {
1974
                        updatedVariables = [v];
×
1975
                    }
1976
                    v.isResolved = true;
×
1977
                } else {
1978
                    updatedVariables = v.childVariables;
×
1979
                }
1980

1981
                // If the variable has no children, set the reference to 0
1982
                // so it does not look expandable in the Ui
1983
                if (v.childVariables.length === 0) {
×
1984
                    v.variablesReference = 0;
×
1985
                }
1986

1987
                // If the variable was resolve in the past we may not have fetched a new temp var
1988
                tempVar ??= v;
×
1989
                if (v?.presentationHint) {
×
1990
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
1991
                } else {
1992
                    v.presentationHint = tempVar.presentationHint;
×
1993
                }
1994

1995
            } else {
1996
                updatedVariables = v.childVariables;
4✔
1997
            }
1998

1999
            // Only send the updated variables if we are not going to trigger an invalidated event.
2000
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2001
            response.body = {
4✔
2002
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2003
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2004
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2005
            };
2006
        } catch (error) {
2007
            logger.error('Error during variablesRequest', error, { args });
×
2008
            response.success = false;
×
2009
            response.message = error?.message ?? 'Error during variablesRequest';
×
2010
        } finally {
2011
            logger.info('end', { response });
4✔
2012
        }
2013
        this.sendResponse(response);
4✔
2014
        if (sendInvalidatedEvent) {
4!
2015
            this.debounceSendInvalidatedEvent(null, frameId);
×
2016
        }
2017
    }
2018

2019
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
199✔
2020
        this.sendInvalidatedEvent(threadId, frameId);
×
2021
    }, 50);
2022

2023

2024
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2025
        if (!updatedVariables || !v) {
4!
2026
            return [];
×
2027
        }
2028

2029
        let start = args.start ?? 0;
4!
2030

2031
        //if the variable is an array, send only the requested range
2032
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2033
            //only send the variable range requested by the debugger
2034
            if (!args.count) {
×
2035
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2036
            } else {
2037
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2038
            }
2039
        }
2040

2041
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2042
            // We currently do not support named variable paging so we always send all named variables
2043
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2044
        }
2045

2046
        let filteredUpdatedVariables = this.launchConfiguration.showHiddenVariables !== true ? updatedVariables.filter(
4✔
2047
            (child: AugmentedVariable) => !child.name.startsWith(this.tempVarPrefix)) : updatedVariables;
6✔
2048

2049
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2050
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2051
                //A transient variable that we show when there is a value
2052
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
2053
                    return true;
×
2054
                } else if (util.isTransientVariable(child.name)) {
4!
2055
                    return false;
×
2056
                } else {
2057
                    return true;
4✔
2058
                }
2059
            });
2060
        }
2061

2062
        return filteredUpdatedVariables;
4✔
2063
    }
2064

2065
    /**
2066
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2067
     * @param v scope variable to populate
2068
     * @param args
2069
     */
2070
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2071
        if (v.childVariables.length > 0) {
4✔
2072
            // Already populated
2073
            return;
3✔
2074
        }
2075

2076
        let tempVar: AugmentedVariable;
2077
        try {
1✔
2078
            if (v.type === '$$Locals') {
1!
2079
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2080
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2081
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2082
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2083
                    // NOTE: Legacy telnet support
2084
                    let variables: AugmentedVariable[] = [];
×
2085
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2086

2087
                    // Fetch each variable individually
2088
                    for (const varName of varNames) {
×
2089
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
2090
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
2091
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
2092
                        variables.push(tempLocalsVar);
×
2093
                    }
2094
                    tempVar = {
×
2095
                        ...v,
2096
                        childVariables: variables,
2097
                        namedVariables: variables.length,
2098
                        indexedVariables: 0
2099
                    };
2100
                }
2101

2102
                // Merge the resulting updates together onto the original variable
2103
                v.childVariables = tempVar.childVariables;
1✔
2104
                v.namedVariables = tempVar.namedVariables;
1✔
2105
                v.indexedVariables = tempVar.indexedVariables;
1✔
2106
            } else if (v.type === '$$Registry') {
×
2107
                // This is a special scope variable used to load registry data via an ECP call
2108
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
2109
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2110
            }
2111
        } catch (error) {
2112
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
2113
            tempVar = {
×
2114
                name: '',
2115
                value: `❌ Error: ${error.message}`,
2116
                variablesReference: 0,
2117
                childVariables: []
2118
            };
2119
            v.childVariables = [tempVar];
×
2120
            v.namedVariables = 1;
×
2121
            v.indexedVariables = 0;
×
2122
        }
2123

2124
        // Mark the scope as resolved so we don't re-fetch the variables
2125
        v.isResolved = true;
1✔
2126

2127
        // If the scope has no children, add a single child to indicate there are no values
2128
        if (v.childVariables.length === 0) {
1!
2129
            tempVar = {
×
2130
                name: '',
2131
                value: `No values for scope '${v.name}'`,
2132
                variablesReference: 0,
2133
                childVariables: []
2134
            };
2135
            v.childVariables = [tempVar];
×
2136
            v.namedVariables = 1;
×
2137
            v.indexedVariables = 0;
×
2138
        }
2139
    }
2140

2141
    private evaluateRequestPromise = Promise.resolve();
199✔
2142
    private evaluateVarIndexByFrameId = new Map<number, number>();
199✔
2143

2144
    private getNextVarIndex(frameId: number): number {
2145
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2146
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2147
        }
2148
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2149
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2150
        return value;
6✔
2151
    }
2152

2153
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2154
        //ensure the rokuAdapter is loaded
2155
        await this.getRokuAdapter();
15✔
2156

2157
        let deferred = defer<void>();
15✔
2158
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
2159
            this.clearState();
×
2160
            this.rokuAdapter.clearCache();
×
2161
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
2162
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
2163
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
2164
            this.sendResponse(response);
×
2165
            return deferred.promise;
×
2166
        }
2167

2168
        try {
15✔
2169
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2170
                return deferred.promise;
15✔
2171
            });
2172

2173
            //fix vscode hover bug that excludes closing quotemark sometimes.
2174
            if (args.context === 'hover') {
15✔
2175
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2176
            }
2177

2178
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2179
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2180
                if (args.context === 'repl') {
1!
2181
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2182
                    response.body = {
1✔
2183
                        result: 'invalid',
2184
                        variablesReference: 0
2185
                    };
2186
                } else {
2187
                    throw new Error(message);
×
2188
                }
2189

2190
                //is at debugger prompt
2191
            } else if (args.expression.trim()) {
14!
2192
                // We trim and check that the expression is not an empty string so that we do not send empty expressions to the Roku
2193
                // This happens mostly when hovering over leading whitespace in the editor
2194

2195
                let { evalArgs, variablePath } = await this.evaluateExpressionToTempVar(args, util.getVariablePath(args.expression));
14✔
2196

2197
                //if we found a variable path (e.g. ['a', 'b', 'c']) then do a variable lookup because it's faster and more widely supported than `evaluate`
2198
                if (variablePath) {
14✔
2199
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2200
                    let v: AugmentedVariable;
2201
                    //if we already looked this item up, return it
2202
                    if (this.variables[refId]) {
11✔
2203
                        v = this.variables[refId];
1✔
2204
                    } else {
2205
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2206
                        if (!result) {
10!
2207
                            throw new Error('Error: unable to evaluate expression');
×
2208
                        }
2209

2210
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2211
                        //TODO - testing something, remove later
2212
                        // eslint-disable-next-line camelcase
2213
                        v.request_seq = response.request_seq;
10✔
2214
                        v.frameId = evalArgs.frameId;
10✔
2215
                    }
2216
                    response.body = {
11✔
2217
                        result: v.value,
2218
                        type: v.type,
2219
                        variablesReference: v.variablesReference,
2220
                        namedVariables: v.namedVariables || 0,
21✔
2221
                        indexedVariables: v.indexedVariables || 0
21✔
2222
                    };
2223

2224
                    //run an `evaluate` call
2225
                } else {
2226
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2227

2228
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2229
                    if (args.context === 'repl') {
3!
2230
                        // Clear variable cache since this action could have side-effects
2231
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2232
                        this.clearState();
3✔
2233
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2234
                    }
2235

2236
                    // If the adapter captured output (probably only telnet), log the results
2237
                    if (typeof commandResults.message === 'string') {
3✔
2238
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2239
                        if (args.context === 'repl') {
1!
2240
                            // If the command was a repl command, send the output to the debug console for the developer as well
2241
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2242
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2243
                        }
2244
                    }
2245

2246
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2247
                        response.body = {
2✔
2248
                            result: 'invalid',
2249
                            variablesReference: 0
2250
                        };
2251
                    } else {
2252
                        response.body = {
1✔
2253
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2254
                            variablesReference: 0
2255
                        };
2256
                    }
2257
                }
2258
            }
2259
        } catch (error) {
2260
            this.logger.error('Error during variables request', error);
×
2261
            response.success = false;
×
2262
            response.message = error?.message ?? error;
×
2263
        }
2264
        try {
15✔
2265
            this.sendResponse(response);
15✔
2266
        } catch { }
2267
        deferred.resolve();
15✔
2268
    }
2269

2270
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2271
        let returnVal = { evalArgs: args, variablePath };
19✔
2272
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2273
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2274
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2275
            let command = '';
6✔
2276
            if (varIndex === 0) {
6✔
2277
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2278
            }
2279
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2280
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2281
            command += statement;
6✔
2282
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2283
            if (commandResults.type === 'error') {
6!
2284
                throw new Error(commandResults.message);
×
2285
            }
2286
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2287
        }
2288
        return returnVal;
19✔
2289
    }
2290

2291
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
2292
        let results = {
×
2293
            evaluations: [],
2294
            bulkVarName: ''
2295
        };
2296
        let storedVariables = [];
×
2297
        let command = '';
×
2298
        for (let i = 0; i < argsArray.length; i++) {
×
2299
            let args = argsArray[i];
×
2300
            let variablePath = variablePathArray[i];
×
2301
            let returnVal = { evalArgs: args, variablePath };
×
2302
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
2303
                let varIndex = this.getNextVarIndex(frameId);
×
2304
                let arrayVarName = this.tempVarPrefix + 'eval';
×
2305
                if (varIndex === 0) {
×
2306
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2307
                }
2308
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
2309
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
2310
                command += statement;
×
2311

2312
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2313
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2314
            }
2315

2316
            results.evaluations[i] = returnVal;
×
2317
        }
2318

2319
        if (command) {
×
2320

2321
            // create a bulk container for the command results
2322
            let varIndex = this.getNextVarIndex(frameId);
×
2323
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2324
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
2325
            for (let storedVariable of storedVariables) {
×
2326
                bulkContainerStatement += `${storedVariable},\n`;
×
2327
            }
2328
            bulkContainerStatement += `]`;
×
2329

2330
            command += bulkContainerStatement;
×
2331

2332
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2333

2334
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2335
            if (commandResults.type === 'error') {
×
2336
                throw new Error(commandResults.message);
×
2337
            }
2338
        }
2339

2340
        return results;
×
2341
    }
2342

2343
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2344
        this.logger.log('completionsRequest', args, request);
20✔
2345
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2346
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2347

2348
        try {
20✔
2349
            let supplyLocalScopeCompletions = false;
20✔
2350

2351
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2352

2353
            if (!closestCompletionDetails) {
20!
2354
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2355
                response.body = {
×
2356
                    targets: []
2357
                };
2358
                return this.sendResponse(response);
×
2359
            }
2360
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2361

2362
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
20✔
2363
            // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2364
            // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2365
            // value is the closing text to append (empty when a closing bracket is already present).
2366
            const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
20✔
2367

2368
            // The span of input each completion replaces. The client requests completions once (at the first
2369
            // character) and then filters the list as the user keeps typing, so without an explicit range that
2370
            // incremental filtering is anchored incorrectly.
2371
            const replaceRange = this.getCompletionReplaceRange(args);
20✔
2372
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
2373
            // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2374
            // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2375
            const lines = args.text.split('\n');
20✔
2376
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
20!
2377
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
20✔
2378

2379
            // Get the completions if the variable path was valid
2380
            if (parentVariablePath) {
20!
2381

2382
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
2383
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
20✔
2384
                    supplyLocalScopeCompletions = true;
4✔
2385
                }
2386

2387
                // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2388
                let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
20✔
2389

2390
                // provide completions for the parent variable if one was found
2391
                if (parentVariable) {
20✔
2392
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2393
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2394
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2395
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2396
                        parentVariable.type === VariableType.List ||
2397
                        parentVariable.type === 'roXMLList' ||
2398
                        parentVariable.type === 'roByteArray';
2399

2400
                    const possibleFieldsAndMethods = isIntegerIndexed
19✔
2401
                        ? []
2402
                        // Filter out virtual variables and the empty-named placeholder used for empty scopes
2403
                        : parentVariable.childVariables.filter((child) => child.name && child.presentationHint?.kind !== 'virtual');
21!
2404

2405
                    for (let v of possibleFieldsAndMethods) {
19✔
2406
                        // Default completion type should be variable
2407
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2408
                        if (!supplyLocalScopeCompletions) {
21✔
2409
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2410
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2411
                                completionType = 'field';
17✔
2412
                            }
2413

2414
                            switch (v.type) {
17!
2415
                                case VariableType.Function:
2416
                                case VariableType.Subroutine:
2417
                                    completionType = 'method';
×
2418
                                    break;
×
2419
                                default:
2420
                                    break;
17✔
2421
                            }
2422
                        }
2423

2424
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2425
                            label: v.name,
2426
                            type: completionType,
2427
                            //rank a variable's own members/locals above everything else
2428
                            sortText: `${CompletionSortTier.Member}${v.name}`
2429
                        };
2430
                        if (stringKeyClosing !== undefined) {
21✔
2431
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2432
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2433
                            // (ex: a key of `a"b` is inserted as `a""b`).
2434
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2435
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2436
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2437
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2438
                            // the key is escaped as `""` so the inserted string literal stays valid.
2439
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2440
                            completionItem.start = replaceRange.start - 1;
3✔
2441
                            completionItem.length = replaceRange.length + 1;
3✔
2442
                        }
2443
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2444
                    }
2445

2446
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2447
                    if (stringKeyClosing === undefined) {
19✔
2448
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2449
                        //assemble a list of all methods on the parent component
2450
                        const methods = [
15✔
2451
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2452
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2453
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2454
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2455
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2456
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2457
                        ].flat();
2458

2459
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2460
                        for (const method of methods) {
15✔
2461
                            completions.set(`method-${method.name}`, {
185✔
2462
                                label: method.name,
2463
                                type: 'method',
2464
                                detail: method.description ?? '',
555!
2465
                                sortText: `${CompletionSortTier.Method}${method.name}`
2466
                            });
2467
                        }
2468
                    }
2469

2470
                    // Add the global functions to the completions results
2471
                    if (supplyLocalScopeCompletions) {
19✔
2472
                        for (let globalCallable of globalCallables) {
4✔
2473
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2474
                                label: globalCallable.name,
2475
                                type: 'function',
2476
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2477
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2478
                            });
2479
                        }
2480

2481
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2482

2483
                        try {
4✔
2484
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2485
                            for (let scopeFunction of scopeFunctions) {
4✔
2486
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2487
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2488
                                        label: scopeFunction.name,
2489
                                        type: scopeFunction.completionItemKind,
2490
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2491
                                    });
2492
                                }
2493
                            }
2494
                        } catch (e) {
2495
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2496
                        }
2497
                    }
2498
                }
2499
            }
2500

2501
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2502
            // rewrites above use an extended range that also consumes the preceding `.`).
2503
            for (const target of completions.values()) {
20✔
2504
                if (target.start === undefined) {
499✔
2505
                    target.start = replaceRange.start;
496✔
2506
                    target.length = replaceRange.length;
496✔
2507
                }
2508
            }
2509

2510
            response.body = {
20✔
2511
                targets: [...completions.values()]
2512
            };
2513
        } catch (error) {
2514
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2515
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2516
            this.logger.error('Error during completionsRequest', error, { args });
×
2517
        }
2518
        this.sendResponse(response);
20✔
2519
    }
2520

2521
    /**
2522
     * Gets the closest completion details the incoming completion request.
2523
     */
2524
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2525
        const incomingText = args.text;
69✔
2526
        const lines = incomingText.split('\n');
69✔
2527
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2528
        let column = this.toDebuggerColumn(args.column);
69✔
2529

2530
        const targetLine = lines[lineNumber] ?? '';
69!
2531

2532
        const cursorIndex = column - 1;
69✔
2533
        const variableChars = /[a-z0-9_\.]/i;
69✔
2534

2535
        // If the character immediately to the right of the cursor is a variable character, then we are
2536
        // in the middle of a token and should not supply completions yet.
2537
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
69✔
2538
            return undefined;
2✔
2539
        }
2540

2541
        // Determine where the expression we want to complete ends, and whether we are completing the
2542
        // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
2543
        // (ex: `m["fo`) are both treated as member access on the parent expression.
2544
        let endColumn = column;
67✔
2545
        let isMemberAccess = false;
67✔
2546
        //when set (including ''), the user is completing a string key; the value is the text to append to
2547
        //close the access (ex: `"]`), empty when a closing bracket is already present
2548
        let stringKeyClosing: string;
2549

2550
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2551
        if (openBracket?.char === '[') {
67✔
2552
            // find the opening quote (skipping any whitespace after the `[`)
2553
            let quoteIndex = openBracket.index + 1;
12✔
2554
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
NEW
2555
                quoteIndex++;
×
2556
            }
2557
            const quote = targetLine[quoteIndex];
12✔
2558
            if (quote === '"' || quote === `'`) {
12✔
2559
                // The user is typing a string key, so complete the keys of the expression before the `[`
2560
                endColumn = openBracket.index;
8✔
2561
                isMemberAccess = true;
8✔
2562
                // close the string and bracket only when there is nothing meaningful after the cursor
2563
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2564
            }
2565
        }
2566

2567
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2568
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2569
        let startIndex = endColumn - 1;
67✔
2570
        let bracketDepth = 0;
67✔
2571
        while (startIndex >= 0) {
67✔
2572
            const char = targetLine[startIndex];
438✔
2573
            if (char === ']') {
438✔
2574
                bracketDepth++;
10✔
2575
            } else if (char === '[') {
428✔
2576
                if (bracketDepth === 0) {
12✔
2577
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2578
                    break;
2✔
2579
                }
2580
                bracketDepth--;
10✔
2581
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2582
                break;
23✔
2583
            }
2584
            startIndex--;
413✔
2585
        }
2586

2587
        const variablePathString = targetLine.slice(startIndex + 1, endColumn);
67✔
2588

2589
        // Attempted dot access on something unexpected.
2590
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2591
        // which leaves `.name` as the variable path string.
2592
        if (variablePathString.startsWith('.')) {
67✔
2593
            return undefined;
2✔
2594
        }
2595

2596
        if (variablePathString.endsWith('.')) {
65✔
2597
            isMemberAccess = true;
25✔
2598
        }
2599

2600
        // Get the variable path from the text
2601
        let variablePath: string[];
2602
        if (!variablePathString.trim()) {
65✔
2603
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2604
            variablePath = [''];
10✔
2605
        } else if (variablePathString.endsWith('.')) {
55✔
2606
            // supplied text ends with a period, so strip it off to create a valid variable path
2607
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2608
        } else {
2609
            variablePath = util.getVariablePath(variablePathString);
30✔
2610
        }
2611

2612
        // the target string is not a valid variable path
2613
        if (!variablePath) {
65✔
2614
            return undefined;
2✔
2615
        }
2616

2617
        // For member access we complete the members of the full expression. Otherwise we complete the
2618
        // siblings of the final (partial) segment, so drop it to get the parent.
2619
        let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
63✔
2620

2621
        // An empty parent path means we are looking up the local scope variables and global functions
2622
        if (parentVariablePath.length === 0) {
63✔
2623
            parentVariablePath = [''];
17✔
2624
        }
2625

2626
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2627
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2628
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2629
            result.stringKeyClosing = stringKeyClosing;
8✔
2630
        }
2631
        return result;
63✔
2632
    }
2633

2634
    /**
2635
     * Compute the span of input text that a completion replaces: the run of identifier characters
2636
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2637
     * typing past the first character.
2638
     *
2639
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2640
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2641
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2642
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2643
     */
2644
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2645
        const lines = args.text.split('\n');
20✔
2646
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2647
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2648
        const targetLine = lines[lineNumber] ?? '';
20!
2649

2650
        const identifierChars = /[a-z0-9_]/i;
20✔
2651
        let wordStart = cursorOffset;
20✔
2652
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2653
            wordStart--;
7✔
2654
        }
2655
        return {
20✔
2656
            start: wordStart,
2657
            length: cursorOffset - wordStart
2658
        };
2659
    }
2660

2661
    /**
2662
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2663
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2664
     */
2665
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2666
        let depth = 0;
67✔
2667
        for (let i = column - 1; i >= 0; i--) {
67✔
2668
            const char = line[i];
604✔
2669
            if (char === ')' || char === ']' || char === '}') {
604✔
2670
                depth++;
12✔
2671
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2672
                if (depth === 0) {
34✔
2673
                    return { index: i, char: char };
22✔
2674
                }
2675
                depth--;
12✔
2676
            }
2677
        }
2678
        return undefined;
45✔
2679
    }
2680

2681
    /**
2682
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2683
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2684
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2685
     */
2686
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2687
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2688
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2689
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2690
        if (isLocalScope) {
20✔
2691
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2692
            if (!localsScope.isResolved) {
4!
2693
                try {
4✔
2694
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2695
                } catch (error) {
NEW
2696
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2697
                }
2698
            }
2699
        }
2700

2701
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2702
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2703
            return inMemory;
14✔
2704
        }
2705

2706
        // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
2707
        // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
2708
        const expression = this.buildVariableExpression(parentVariablePath);
6✔
2709

2710
        const cacheKey = `${frameId}:${expression}`;
6✔
2711
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2712
            return this.completionParentVariableCache.get(cacheKey);
1✔
2713
        }
2714

2715
        let parentVariable: AugmentedVariable;
2716
        try {
5✔
2717
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2718
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2719
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2720
        } catch (error) {
2721
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2722
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2723
            parentVariable = undefined;
1✔
2724
        }
2725

2726
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2727
        return parentVariable;
5✔
2728
    }
2729

2730
    /**
2731
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2732
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2733
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2734
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2735
     */
2736
    private buildVariableExpression(segments: string[]): string {
2737
        return segments.reduce((expression, segment, index) => {
14✔
2738
            if (index === 0) {
27✔
2739
                return segment;
14✔
2740
            }
2741
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2742
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2743
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2744
                return `${expression}[${segment}]`;
2✔
2745
            }
2746
            if (/^[0-9]+$/.test(segment)) {
11✔
2747
                return `${expression}[${segment}]`;
3✔
2748
            }
2749
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
2750
                return `${expression}.${segment}`;
5✔
2751
            }
2752
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
2753
        }, '');
2754
    }
2755

2756
    /**
2757
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
2758
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
2759
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
2760
     * the user typed (ex: `topRef` matching the cached `topref`).
2761
     */
2762
    private normalizeVariableName(name: string): string {
2763
        let value = name ?? '';
46!
2764
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
2765
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
2766
        }
2767
        return value.toLowerCase();
46✔
2768
    }
2769

2770
    /**
2771
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
2772
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
2773
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
2774
     */
2775
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
2776
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
2777
        if (path.length === 1 && path[0] === '') {
20✔
2778
            return localsContainer;
4✔
2779
        }
2780
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
2781
    }
2782

2783
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
2784
        let current: AugmentedVariable = null;
22✔
2785
        for (const name of path) {
22✔
2786
            const normalizedName = this.normalizeVariableName(name);
26✔
2787
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
2788
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
2789
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
2790
            });
2791

2792
            // If no match is found, return null
2793
            if (!current) {
26✔
2794
                return null;
7✔
2795
            }
2796

2797
            // Move to the children for the next iteration
2798
            variables = current.childVariables;
19✔
2799
        }
2800
        return current;
15✔
2801
    }
2802

2803
    private debuggerVarTypeToRoType(type: string): string {
2804
        switch (type) {
15!
2805
            case VariableType.Function:
2806
            case VariableType.Subroutine:
2807
                return 'roFunction';
×
2808
            case VariableType.AssociativeArray:
2809
                return 'roAssociativeArray';
11✔
2810
            case VariableType.List:
2811
                return 'roList';
×
2812
            case VariableType.Array:
2813
                return 'roArray';
1✔
2814
            case VariableType.Boolean:
2815
                return 'roBoolean';
×
2816
            case VariableType.Double:
2817
                return 'roDouble';
×
2818
            case VariableType.Float:
2819
                return 'roFloat';
×
2820
            case VariableType.Integer:
2821
                return 'roInteger';
×
2822
            case VariableType.LongInteger:
2823
                return 'roLongInteger';
×
2824
            case VariableType.String:
2825
                return 'roString';
×
2826
            default:
2827
                return type;
3✔
2828
        }
2829
    }
2830

2831
    /**
2832
     * Called when the host stops debugging
2833
     * @param response
2834
     * @param args
2835
     */
2836
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
2837
        //return to the home screen — best effort. The device may already be powered off or unreachable
2838
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
2839
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
2840
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
2841
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
2842
        //    https://github.com/rokucommunity/roku-debug/issues/332
2843
        if (!this.enableDebugProtocol) {
2!
2844
            try {
2✔
2845
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
2846
            } catch (e) {
2847
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
2848
            }
2849
        }
2850
        this.sendResponse(response);
2✔
2851
        await this.shutdown();
2✔
2852
    }
2853

2854
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
2855
        if (this.enableDebugProtocol) {
1!
2856
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
2857
        } else {
2858
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
2859
        }
2860
    }
2861

2862
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
2863
        this.logger.log('[restartRequest] begin');
×
2864
        if (this.rokuAdapter) {
×
2865
            if (!this.enableDebugProtocol) {
×
2866
                this.rokuAdapter.removeAllListeners();
×
2867
            }
2868
            await this.rokuAdapter.destroy();
×
2869
            await this.ensureAppIsInactive();
×
2870
            this.rokuAdapterDeferred = defer();
×
2871
            this.stagingDefered.tryResolve();
×
2872
            this.stagingDefered = defer();
×
2873
        }
2874
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
2875
    }
2876

2877
    private exitAppTimeout = 5000;
199✔
2878
    private async ensureAppIsInactive() {
2879
        const startTime = Date.now();
×
2880

2881
        while (true) {
×
2882
            if (Date.now() - startTime > this.exitAppTimeout) {
×
2883
                return;
×
2884
            }
2885

2886
            try {
×
2887
                let appStateResult = await rokuECP.getAppState({
×
2888
                    host: this.launchConfiguration.host,
2889
                    remotePort: this.launchConfiguration.remotePort,
2890
                    appId: 'dev',
2891
                    requestOptions: { timeout: 300 }
2892
                });
2893

2894
                const state = appStateResult.state;
×
2895

2896
                if (state === AppState.active || state === AppState.background) {
×
2897
                    // Suspends or terminates an app that is running:
2898
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
2899
                    // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app.
2900
                    // This means that we might need to send this command twice to terminate the app.
2901
                    await rokuECP.exitApp({
×
2902
                        host: this.launchConfiguration.host,
2903
                        remotePort: this.launchConfiguration.remotePort,
2904
                        appId: 'dev',
2905
                        requestOptions: { timeout: 300 }
2906
                    });
2907
                } else if (state === AppState.inactive) {
×
2908
                    return;
×
2909
                }
2910
            } catch (e) {
2911
                this.logger.error('Error attempting to exit application', e);
×
2912
            }
2913

2914
            await util.sleep(200);
×
2915
        }
2916
    }
2917

2918
    /**
2919
     * Used to track whether the entry breakpoint has already been handled
2920
     */
2921
    private entryBreakpointWasHandled = false;
199✔
2922

2923
    /**
2924
     * Registers the main events for the RokuAdapter
2925
     */
2926
    private async connectRokuAdapter() {
2927
        this.rokuAdapter.on('start', () => {
×
2928
            this.sendLaunchProgress('end', 'Complete');
×
2929
            if (!this.firstRunDeferred.isCompleted) {
×
2930
                this.firstRunDeferred.resolve();
×
2931
            }
2932
        });
2933

2934
        this.rokuAdapter.on('launch-status', (message) => {
×
2935
            this.sendLaunchProgress('update', message);
×
2936
        });
2937

2938
        //when the debugger suspends (pauses for debugger input)
2939
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
2940
        this.rokuAdapter.on('suspend', async () => {
×
2941
            await this.onSuspend();
×
2942
        });
2943

2944
        //anytime the adapter encounters an exception on the roku,
2945
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
2946
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
2947
            await this.getRokuAdapter();
×
2948
            const threads = await this.setupSuspendedState();
×
2949
            let threadId = threads[0]?.threadId;
×
2950
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
2951
        });
2952

2953
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
2954
        this.rokuAdapter.on('cannot-continue', () => {
×
2955
            this.shutdown().catch(e => this.logger.error(e));
×
2956
        });
2957

2958
        //make the connection
2959
        await this.rokuAdapter.connect();
×
2960
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
2961
        return this.rokuAdapter;
×
2962
    }
2963

2964
    private async onSuspend() {
2965
        const threads = await this.setupSuspendedState();
1✔
2966
        const activeThread = threads.find(x => x.isSelected);
1✔
2967

2968
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
2969
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
2970
            this.entryBreakpointWasHandled = true;
1✔
2971
            //if there's a user-defined breakpoint at this exact position, it needs to be handled like a regular breakpoint (i.e. suspend). So only auto-continue if there's no breakpoint here
2972
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
2973
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
2974
                return this.rokuAdapter.continue();
×
2975
            }
2976
        }
2977

2978
        const event: StoppedEvent = new StoppedEvent(
1✔
2979
            StoppedEventReason.breakpoint,
2980
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
2981
            activeThread?.threadId ?? 0,
6!
2982
            '' //exception text
2983
        );
2984
        // Socket debugger will always stop all threads and supports multi thread inspection.
2985
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
2986
        this.sendEvent(event);
1✔
2987
    }
2988

2989
    private async setupSuspendedState() {
2990
        //clear the index for storing evalutated expressions
2991
        this.evaluateVarIndexByFrameId.clear();
8✔
2992

2993
        const threads = await this.rokuAdapter.getThreads();
8✔
2994

2995
        //TODO remove this once Roku fixes their threads off-by-one line number issues
2996
        //look up the correct line numbers for each thread from the StackTrace
2997
        await Promise.all(
8✔
2998
            threads.map(async (thread) => {
2999
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3000
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3001
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3002
                // Only apply the line correction when we actually have valid data — never clobber
3003
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3004
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3005
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3006
                    thread.lineNumber = stackTraceLineNumber;
2✔
3007
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3008
                }
3009
            })
3010
        );
3011

3012
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3013
            for (const thread of threads) {
4✔
3014
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3015
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3016
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3017
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3018
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
3019
                        this.logger.error('Error showing popup message', { error });
×
3020
                    });
3021
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3022
                    break outer;
1✔
3023
                }
3024
            }
3025
        }
3026

3027
        //sync breakpoints
3028
        await this.rokuAdapter?.syncBreakpoints();
8!
3029

3030
        this.logger.info('received "suspend" event from adapter');
8✔
3031

3032
        this.clearState();
8✔
3033
        return threads;
8✔
3034
    }
3035

3036
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3037
        let v: AugmentedVariable;
3038

3039
        if (result) {
25!
3040
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3041
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3042
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
3043
                    try {
×
3044
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3045
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
3046
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3047
                        this.mergeEvaluateContainers(result, newResult);
×
3048
                    } catch (error) {
3049
                        logger.error('Error getting variables', error);
×
3050
                        this.mergeEvaluateContainers(result, {
×
3051
                            name: result.name,
3052
                            evaluateName: result.evaluateName,
3053
                            children: [],
3054
                            value: `❌ Error: ${error.message}`,
3055
                            type: '',
3056
                            highLevelType: undefined,
3057
                            keyType: undefined
3058
                        });
3059
                    }
3060
                }
3061

3062
                if (result.keyType) {
16✔
3063
                    let value = `${result.value ?? result.type}`;
5!
3064
                    let indexedVariables = result.indexedVariables;
5✔
3065
                    let namedVariables = result.namedVariables;
5✔
3066

3067
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3068
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3069
                        // by supplying undefined values for both
3070
                        indexedVariables = undefined;
×
3071
                        namedVariables = undefined;
×
3072
                    }
3073

3074
                    // check to see if this is an dictionary or a list
3075
                    if (result.keyType === 'Integer') {
5!
3076
                        // list type
3077
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3078
                    } else if (result.keyType === 'String') {
5!
3079
                        // dictionary type
3080
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3081
                    }
3082
                    v.type = result.type;
5✔
3083
                } else {
3084

3085
                    let value: string;
3086
                    if (result.type === VariableType.Invalid) {
11!
3087
                        value = result.value ?? 'Invalid';
×
3088
                    } else if (result.type === VariableType.Uninitialized) {
11!
3089
                        value = 'Uninitialized';
×
3090
                    } else {
3091
                        value = `${result.value}`;
11✔
3092
                    }
3093
                    // If the variable is lazy we must assign a refId to inform the system
3094
                    // to request this variable again in the future for value resolution
3095
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3096
                }
3097
                this.variables[refId] = v;
16✔
3098
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3099
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3100
                    v = new Variable(result.name, `${result.value}`);
7✔
3101
                } else if (result.highLevelType === 'array') {
2✔
3102
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3103
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3104
                    this.variables[refId] = v;
1✔
3105
                } else if (result.highLevelType === 'object') {
1!
3106
                    let refId: number;
3107
                    //handle collections
3108
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3109
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3110
                    }
3111
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3112
                    this.variables[refId] = v;
1✔
3113
                } else if (result.highLevelType === 'function') {
×
3114
                    v = new Variable(result.name, `${result.value}`);
×
3115
                } else {
3116
                    //all other cases, but mostly for HighLevelType.unknown
3117
                    v = new Variable(result.name, `${result.value}`);
×
3118
                }
3119
            }
3120

3121
            v.type = result.type;
25✔
3122
            v.evaluateName = result.evaluateName;
25✔
3123
            v.frameId = frameId;
25✔
3124
            v.type = result.type;
25✔
3125
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3126
            if (util.isTransientVariable(v.name)) {
25!
3127
                v.presentationHint = { kind: 'virtual' };
×
3128
            }
3129

3130
            if (result.children && maxDepth > 0) {
25✔
3131
                if (!v.childVariables) {
7!
3132
                    v.childVariables = [];
7✔
3133
                }
3134

3135
                // Create a mapping of the children to their index so we can evaluate them in bulk
3136
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3137
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3138
                    return remapped;
10✔
3139
                });
3140
                if (this.enableDebugProtocol) {
7!
3141
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
3142
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
3143
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3144
                    });
3145

3146
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3147
                        return util.getVariablePath(x.child.evaluateName);
×
3148
                    });
3149

3150
                    try {
×
3151
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
3152
                        if (bulkEvaluations.bulkVarName) {
×
3153
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
3154
                            childrenToEvaluate.map((mappedChild, index) => {
×
3155
                                let newResult = newResults.children[index];
×
3156
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
3157
                                mappedChild.child.evaluateNow = false;
×
3158
                                return mappedChild;
×
3159
                            });
3160
                        }
3161
                    } catch (error) {
3162
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3163
                    }
3164
                }
3165
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3166
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3167
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3168
                }));
3169
            } else {
3170
                v.childVariables = [];
18✔
3171
            }
3172

3173
            // if the var is an array and debugProtocol is enabled, include the array size
3174
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
3175
                if (isNaN(result.indexedVariables as number)) {
×
3176
                    v.value = v.type;
×
3177
                } else {
3178
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3179
                }
3180
            }
3181
        }
3182
        return v;
25✔
3183
    }
3184

3185
    /**
3186
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3187
     * Used primarily for custom variables
3188
     */
3189
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3190
        original.children = updated.children;
×
3191
        original.value = updated.value;
×
3192
        original.type = updated.type;
×
3193
        original.highLevelType = updated.highLevelType;
×
3194
        original.keyType = updated.keyType;
×
3195
        original.indexedVariables = updated.indexedVariables;
×
3196
        original.namedVariables = updated.namedVariables;
×
3197
    }
3198

3199
    private getEvaluateRefId(expression: string, frameId: number) {
3200
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3201
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3202
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3203
        }
3204
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3205
    }
3206

3207
    private clearState() {
3208
        //erase all cached variables
3209
        this.variables = {};
12✔
3210
        this.completionParentVariableCache.clear();
12✔
3211
    }
3212

3213
    /**
3214
     * Sends a launch progress event to the client if the client supports progress reporting.
3215
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3216
     * - `'update'`: updates the message on the active progress bar.
3217
     * - `'end'`: dismisses the active progress bar with an optional final message.
3218
     */
3219
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3220
        if (!this.initRequestArgs?.supportsProgressReporting) {
60✔
3221
            return;
29✔
3222
        }
3223
        if (type === 'start') {
31✔
3224
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3225
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3226
        } else if (this.launchProgressId) {
21✔
3227
            if (type === 'update') {
18✔
3228
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3229
            } else {
3230
                const lastId = this.launchProgressId;
5✔
3231
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3232
                setTimeout(() => {
5✔
3233
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3234
                }, 1000); // add a slight delay before ending the progress to improve UX
3235
                this.launchProgressId = undefined;
5✔
3236
            }
3237
        }
3238
    }
3239

3240
    /**
3241
     * Tells the client to re-request all variables because we've invalidated them
3242
     * @param threadId
3243
     * @param stackFrameId
3244
     */
3245
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3246
        //if the client supports this request, send it
3247
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
3248
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3249
        }
3250
    }
3251

3252
    /**
3253
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3254
     */
3255
    public async handleEntryBreakpoint() {
3256
        if (!this.enableDebugProtocol) {
4!
3257
            this.entryBreakpointWasHandled = true;
4✔
3258
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3259
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3260
            }
3261
        }
3262
    }
3263

3264
    /**
3265
     * Converts a debugger line number to a client line number.
3266
     *
3267
     * @param debuggerLine - The line number from the debugger as zero based.
3268
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3269
     * @returns The corresponding client line number.
3270
     */
3271
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3272
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3273
    }
3274

3275
    /**
3276
     * Converts a debugger column number to a client column number.
3277
     *
3278
     * @param debuggerLine - The column number from the debugger as zero based.
3279
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3280
     * @returns The corresponding client column number.
3281
     */
3282
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3283
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3284
    }
3285

3286
    /**
3287
     * Converts a client line number to a debugger line number.
3288
     *
3289
     * @param clientLine - The line number from the client.
3290
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3291
     * @returns The corresponding debugger line number as zero based.
3292
     */
3293
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3294
        if (typeof clientLine === 'number') {
109✔
3295
            return this.convertClientLineToDebugger(clientLine);
2✔
3296
        }
3297
        return defaultDebuggerLine;
107✔
3298
    }
3299

3300
    /**
3301
     * Converts a client column number to a debugger column number.
3302
     *
3303
     * @param clientLine - The column number from the client.
3304
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3305
     * @returns The corresponding debugger column number as zero based.
3306
     */
3307
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3308
        if (typeof clientLine === 'number') {
89!
3309
            return this.convertClientColumnToDebugger(clientLine);
89✔
3310
        }
3311
        return defaultDebuggerLine;
×
3312
    }
3313

3314
    private shutdownPromise: Promise<void> | undefined = undefined;
199✔
3315

3316
    /**
3317
     * Called when the debugger is terminated. Feel free to call this as frequently as you want; we'll only run the shutdown process the first time, and return
3318
     * the same promise on subsequent calls
3319
     */
3320
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
15✔
3321
        if (this.shutdownPromise === undefined) {
15!
3322
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
15✔
3323
            this.shutdownPromise = this._shutdown(errorMessage, modal);
15✔
3324
        } else {
3325
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3326
        }
3327
        return this.shutdownPromise;
15✔
3328
    }
3329

3330
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
×
3331
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3332
        this.sendLaunchProgress('end', 'Complete');
15✔
3333

3334
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3335
        try {
15✔
3336
            if (errorMessage) {
15!
3337
                this.logger.error(errorMessage);
×
3338
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
3339
                    this.logger.error('Error showing popup message', { error });
×
3340
                });
3341
            }
3342
        } catch (e) {
3343
            this.logger.error(e);
×
3344
        }
3345
        // stop perfetto tracing if it's running
3346
        try {
15✔
3347
            await this.perfettoManager.stopTracing();
15✔
3348
        } catch (e) {
3349
            this.logger.error('Error stopping perfetto tracing', e);
15✔
3350
        }
3351

3352
        try {
15✔
3353
            await this.perfettoManager?.dispose?.();
15!
3354
        } catch (e) {
3355
            this.logger.error('Error disposing perfetto manager', e);
×
3356
        }
3357

3358
        //close the debugger connection
3359
        try {
15✔
3360
            this.logger.log('Destroy rokuAdapter');
15✔
3361
            await this.rokuAdapter?.destroy?.();
15!
3362
            //press the home button to return to the home screen
3363
            try {
15✔
3364
                this.logger.log('Press home button');
15✔
3365
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
15✔
3366
            } catch (e) {
3367
                this.logger.error(e);
×
3368
            }
3369
        } catch (e) {
3370
            this.logger.error(e);
×
3371
        }
3372

3373
        try {
15✔
3374
            this.projectManager?.dispose?.();
15!
3375
        } catch (e) {
3376
            this.logger.error(e);
×
3377
        }
3378

3379
        try {
15✔
3380
            this.componentLibraryServer?.stop();
15!
3381
        } catch (e) {
3382
            this.logger.error(e);
×
3383
        }
3384

3385
        try {
15✔
3386
            await this.rendezvousTracker?.destroy?.();
15!
3387
        } catch (e) {
3388
            this.logger.error(e);
×
3389
        }
3390

3391
        try {
15✔
3392
            await this.sourceMapManager?.destroy?.();
15!
3393
        } catch (e) {
3394
            this.logger.error(e);
×
3395
        }
3396

3397
        try {
15✔
3398
            //if configured, delete the staging directory
3399
            if (!this.launchConfiguration.retainStagingFolder) {
15!
3400
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
15!
3401
                this.logger.info('deleting staging folders', stagingDirs);
15✔
3402
                for (let stagingDir of stagingDirs) {
15✔
3403
                    try {
2✔
3404
                        fsExtra.removeSync(stagingDir);
2✔
3405
                    } catch (e) {
3406
                        this.logger.error(e);
×
3407
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3408
                    }
3409
                }
3410
            }
3411
        } catch (e) {
3412
            this.logger.error(e);
×
3413
        }
3414

3415
        try {
15✔
3416
            this.logger.log('Send terminated event');
15✔
3417
            this.sendEvent(new TerminatedEvent());
15✔
3418

3419
            //shut down the process
3420
            this.logger.log('super.shutdown()');
15✔
3421
            super.shutdown();
15✔
3422
            this.logger.log('shutdown complete');
15✔
3423
        } catch (e) {
3424
            this.logger.error(e);
×
3425
        }
3426

3427
        try {
15✔
3428
            this.teardownProcessErrorHandlers();
15✔
3429
        } catch (e) {
3430
            this.logger.error(e);
×
3431
        }
3432
    }
3433
}
3434

3435
export interface AugmentedVariable extends DebugProtocol.Variable {
3436
    childVariables?: AugmentedVariable[];
3437
    // eslint-disable-next-line camelcase
3438
    request_seq?: number;
3439
    frameId?: number;
3440
    /**
3441
     * only used for lazy variables
3442
     */
3443
    isResolved?: boolean;
3444
    /**
3445
     * used to indicate that this variable is a scope variable
3446
     * and may require special handling
3447
     */
3448
    isScope?: boolean;
3449
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc