roku-debug 0.23.13 → 0.23.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ export declare class BrightScriptDebugSession extends LoggingDebugSession {
15
15
  setupProcessErrorHandlers(): void;
16
16
  teardownProcessErrorHandlers(): void;
17
17
  private onDeviceBreakpointsChanged;
18
- logger: import("@rokucommunity/logger/dist/Logger").Logger;
18
+ logger: import("@rokucommunity/logger").Logger;
19
19
  private readonly isWindowsPlatform;
20
20
  /**
21
21
  * A sequence used to help identify log statements for requests
@@ -45,6 +45,12 @@ export declare class BrightScriptDebugSession extends LoggingDebugSession {
45
45
  private evaluateRefIdLookup;
46
46
  private evaluateRefIdCounter;
47
47
  private variables;
48
+ /**
49
+ * Caches the device lookups performed while resolving completion requests. Variables don't change
50
+ * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
51
+ * `clearState` whenever the debugger resumes or steps.
52
+ */
53
+ private completionParentVariableCache;
48
54
  private rokuAdapter;
49
55
  private perfettoManager;
50
56
  private rendezvousTracker;
@@ -187,6 +193,11 @@ export declare class BrightScriptDebugSession extends LoggingDebugSession {
187
193
  private getThreadName;
188
194
  protected stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments): Promise<void>;
189
195
  protected scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments): Promise<void>;
196
+ /**
197
+ * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
198
+ * The child variables are filled in lazily by `populateScopeVariables`.
199
+ */
200
+ private getOrCreateLocalsScope;
190
201
  protected continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments): Promise<void>;
191
202
  protected pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments): Promise<void>;
192
203
  protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments): void;
@@ -219,6 +230,48 @@ export declare class BrightScriptDebugSession extends LoggingDebugSession {
219
230
  * Gets the closest completion details the incoming completion request.
220
231
  */
221
232
  private getClosestCompletionDetails;
233
+ /**
234
+ * Compute the span of input text that a completion replaces: the run of identifier characters
235
+ * immediately before the cursor. This lets the client filter the list correctly as the user keeps
236
+ * typing past the first character.
237
+ *
238
+ * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
239
+ * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
240
+ * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
241
+ * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
242
+ */
243
+ private getCompletionReplaceRange;
244
+ /**
245
+ * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
246
+ * been closed before the cursor. Returns the opener's index and character, or undefined if none.
247
+ */
248
+ private findUnclosedOpener;
249
+ /**
250
+ * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
251
+ * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
252
+ * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
253
+ */
254
+ private resolveCompletionParentVariable;
255
+ /**
256
+ * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
257
+ * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
258
+ * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
259
+ * use dot access. This keeps array indices and string keys correct through the device lookup.
260
+ */
261
+ private buildVariableExpression;
262
+ /**
263
+ * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
264
+ * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
265
+ * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
266
+ * the user typed (ex: `topRef` matching the cached `topref`).
267
+ */
268
+ private normalizeVariableName;
269
+ /**
270
+ * Resolve a variable path against the current frame's local scope. The first path segment is matched
271
+ * against the frame's locals (not the global pool of every materialized variable), then we walk down
272
+ * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
273
+ */
274
+ private findFrameVariableByPath;
222
275
  private findVariableByPath;
223
276
  private debuggerVarTypeToRoType;
224
277
  /**
@@ -32,6 +32,17 @@ const ecpRegistryUtils_1 = require("./ecpRegistryUtils");
32
32
  const RokuECP_1 = require("../RokuECP");
33
33
  const Exceptions_1 = require("../Exceptions");
34
34
  const diagnosticSource = 'roku-debug';
35
+ /**
36
+ * Sort tiers for debug-console completions. Lower values sort first, so a variable's own members rank
37
+ * above interface methods, then the file's scope functions, and finally the (large) set of globals.
38
+ */
39
+ var CompletionSortTier;
40
+ (function (CompletionSortTier) {
41
+ CompletionSortTier["Member"] = "1";
42
+ CompletionSortTier["Method"] = "2";
43
+ CompletionSortTier["ScopeFunction"] = "3";
44
+ CompletionSortTier["Global"] = "4";
45
+ })(CompletionSortTier || (CompletionSortTier = {}));
35
46
  class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
36
47
  constructor() {
37
48
  super();
@@ -58,6 +69,12 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
58
69
  this.evaluateRefIdLookup = {};
59
70
  this.evaluateRefIdCounter = 1;
60
71
  this.variables = {};
72
+ /**
73
+ * Caches the device lookups performed while resolving completion requests. Variables don't change
74
+ * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
75
+ * `clearState` whenever the debugger resumes or steps.
76
+ */
77
+ this.completionParentVariableCache = new Map();
61
78
  this.tempVarPrefix = '__rokudebug__';
62
79
  /**
63
80
  * 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
@@ -1202,6 +1219,7 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
1202
1219
  sourceDirs: componentLibrary.sourceDirs,
1203
1220
  bsConst: componentLibrary.bsConst,
1204
1221
  install: componentLibrary.install,
1222
+ enablePostfix: componentLibrary.enablePostfix,
1205
1223
  injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1206
1224
  raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1207
1225
  libraryIndex: libraryIndex,
@@ -1453,24 +1471,8 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
1453
1471
  logger.info('begin', { args });
1454
1472
  try {
1455
1473
  const scopes = new Array();
1456
- let v;
1457
1474
  // create the locals scope
1458
- let localsRefId = this.getEvaluateRefId('$$locals', args.frameId);
1459
- if (this.variables[localsRefId]) {
1460
- v = this.variables[localsRefId];
1461
- }
1462
- else {
1463
- v = {
1464
- variablesReference: localsRefId,
1465
- name: 'Locals',
1466
- value: '',
1467
- type: '$$Locals',
1468
- frameId: args.frameId,
1469
- isScope: true,
1470
- childVariables: []
1471
- };
1472
- this.variables[localsRefId] = v;
1473
- }
1475
+ let v = this.getOrCreateLocalsScope(args.frameId);
1474
1476
  let localScope = {
1475
1477
  name: 'Local',
1476
1478
  variablesReference: v.variablesReference,
@@ -1515,6 +1517,25 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
1515
1517
  logger.error('Error getting scopes', { error, args });
1516
1518
  }
1517
1519
  }
1520
+ /**
1521
+ * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
1522
+ * The child variables are filled in lazily by `populateScopeVariables`.
1523
+ */
1524
+ getOrCreateLocalsScope(frameId) {
1525
+ const refId = this.getEvaluateRefId('$$locals', frameId);
1526
+ if (!this.variables[refId]) {
1527
+ this.variables[refId] = {
1528
+ variablesReference: refId,
1529
+ name: 'Locals',
1530
+ value: '',
1531
+ type: '$$Locals',
1532
+ frameId: frameId,
1533
+ isScope: true,
1534
+ childVariables: []
1535
+ };
1536
+ }
1537
+ return this.variables[refId];
1538
+ }
1518
1539
  async continueRequest(response, args) {
1519
1540
  //if we have a compile error, we should shut down
1520
1541
  if (this.compileError) {
@@ -2009,7 +2030,7 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2009
2030
  return results;
2010
2031
  }
2011
2032
  async completionsRequest(response, args, request) {
2012
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
2033
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
2013
2034
  this.logger.log('completionsRequest', args, request);
2014
2035
  // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2015
2036
  // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
@@ -2025,30 +2046,41 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2025
2046
  }
2026
2047
  let completions = new Map();
2027
2048
  let parentVariablePath = closestCompletionDetails.parentVariablePath;
2049
+ // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2050
+ // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2051
+ // value is the closing text to append (empty when a closing bracket is already present).
2052
+ const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
2053
+ // The span of input each completion replaces. The client requests completions once (at the first
2054
+ // character) and then filters the list as the user keeps typing, so without an explicit range that
2055
+ // incremental filtering is anchored incorrectly.
2056
+ const replaceRange = this.getCompletionReplaceRange(args);
2057
+ // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
2058
+ // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2059
+ // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2060
+ const lines = args.text.split('\n');
2061
+ const targetLine = (_a = lines[this.toDebuggerLine(args.line, 0)]) !== null && _a !== void 0 ? _a : '';
2062
+ const precededByDot = targetLine[replaceRange.start - 1] === '.';
2028
2063
  // Get the completions if the variable path was valid
2029
2064
  if (parentVariablePath) {
2030
2065
  // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
2031
2066
  if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
2032
2067
  supplyLocalScopeCompletions = true;
2033
2068
  }
2034
- // Look up the parent variable
2035
- let parentVariable = this.findVariableByPath(Object.values(this.variables), parentVariablePath, args.frameId);
2036
- if (!parentVariable || parentVariable.childVariables.length === 0) {
2037
- // We did not find the parent variable, so try to look it up from the device
2038
- try {
2039
- let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: parentVariablePath.join('.'), frameId: args.frameId }, parentVariablePath);
2040
- let result = await this.rokuAdapter.getVariable(evalArgs.expression, args.frameId);
2041
- parentVariable = await this.getVariableFromResult(result, args.frameId);
2042
- }
2043
- catch (error) {
2044
- this.logger.error('Error looking up parent completions', error, { parentVariablePath });
2045
- }
2046
- }
2069
+ // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2070
+ let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
2047
2071
  // provide completions for the parent variable if one was found
2048
2072
  if (parentVariable) {
2049
- let possibleFieldsAndMethods = [];
2050
- // Filter out virtual variables
2051
- possibleFieldsAndMethods = parentVariable.childVariables.filter((v) => { var _a; return ((_a = v.presentationHint) === null || _a === void 0 ? void 0 : _a.kind) !== 'virtual'; });
2073
+ // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2074
+ // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2075
+ // Only the interface methods below (Count, Push, ...) apply to these containers.
2076
+ const isIntegerIndexed = parentVariable.type === VariablesResponse_1.VariableType.Array ||
2077
+ parentVariable.type === VariablesResponse_1.VariableType.List ||
2078
+ parentVariable.type === 'roXMLList' ||
2079
+ parentVariable.type === 'roByteArray';
2080
+ const possibleFieldsAndMethods = isIntegerIndexed
2081
+ ? []
2082
+ // Filter out virtual variables and the empty-named placeholder used for empty scopes
2083
+ : parentVariable.childVariables.filter((child) => { var _a; return child.name && ((_a = child.presentationHint) === null || _a === void 0 ? void 0 : _a.kind) !== 'virtual'; });
2052
2084
  for (let v of possibleFieldsAndMethods) {
2053
2085
  // Default completion type should be variable
2054
2086
  let completionType = 'variable';
@@ -2066,37 +2098,49 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2066
2098
  break;
2067
2099
  }
2068
2100
  }
2069
- let label = v.name;
2070
- if (parentVariable.type === VariablesResponse_1.VariableType.Array ||
2071
- parentVariable.type === VariablesResponse_1.VariableType.List ||
2072
- parentVariable.type === 'roXMLList' ||
2073
- parentVariable.type === 'roByteArray') {
2074
- label = `[${v.name}]`;
2075
- }
2076
- completions.set(`${completionType}-${v.name}`, {
2077
- label: label,
2101
+ const completionItem = {
2102
+ label: v.name,
2078
2103
  type: completionType,
2079
- sortText: '000000'
2080
- });
2104
+ //rank a variable's own members/locals above everything else
2105
+ sortText: `${CompletionSortTier.Member}${v.name}`
2106
+ };
2107
+ if (stringKeyClosing !== undefined) {
2108
+ // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2109
+ // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2110
+ // (ex: a key of `a"b` is inserted as `a""b`).
2111
+ completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
2112
+ }
2113
+ else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
2114
+ // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2115
+ // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2116
+ // the key is escaped as `""` so the inserted string literal stays valid.
2117
+ completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
2118
+ completionItem.start = replaceRange.start - 1;
2119
+ completionItem.length = replaceRange.length + 1;
2120
+ }
2121
+ completions.set(`${completionType}-${v.name}`, completionItem);
2081
2122
  }
2082
- let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
2083
- //assemble a list of all methods on the parent component
2084
- const methods = [
2085
- //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2086
- ...(_b = (_a = roku_types_1.interfaces[parentComponentType]) === null || _a === void 0 ? void 0 : _a.methods) !== null && _b !== void 0 ? _b : [],
2087
- //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2088
- ...(_d = (_c = roku_types_1.components[parentComponentType]) === null || _c === void 0 ? void 0 : _c.interfaces.map((i) => { var _a; return (_a = roku_types_1.interfaces[i.name.toLowerCase()]) === null || _a === void 0 ? void 0 : _a.methods; })) !== null && _d !== void 0 ? _d : [],
2089
- // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2090
- ...(_f = (_e = roku_types_1.events[parentComponentType]) === null || _e === void 0 ? void 0 : _e.methods) !== null && _f !== void 0 ? _f : []
2091
- ].flat();
2092
- // Based on the results of interface, component, and event looks up, add all the methods to the completions
2093
- for (const method of methods) {
2094
- completions.set(`method-${method.name}`, {
2095
- label: method.name,
2096
- type: 'method',
2097
- detail: (_g = method.description) !== null && _g !== void 0 ? _g : '',
2098
- sortText: '000000'
2099
- });
2123
+ // Interface methods aren't valid string keys, so skip them when completing a string key
2124
+ if (stringKeyClosing === undefined) {
2125
+ let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
2126
+ //assemble a list of all methods on the parent component
2127
+ const methods = [
2128
+ //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2129
+ ...(_c = (_b = roku_types_1.interfaces[parentComponentType]) === null || _b === void 0 ? void 0 : _b.methods) !== null && _c !== void 0 ? _c : [],
2130
+ //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2131
+ ...(_e = (_d = roku_types_1.components[parentComponentType]) === null || _d === void 0 ? void 0 : _d.interfaces.map((i) => { var _a; return (_a = roku_types_1.interfaces[i.name.toLowerCase()]) === null || _a === void 0 ? void 0 : _a.methods; })) !== null && _e !== void 0 ? _e : [],
2132
+ // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2133
+ ...(_g = (_f = roku_types_1.events[parentComponentType]) === null || _f === void 0 ? void 0 : _f.methods) !== null && _g !== void 0 ? _g : []
2134
+ ].flat();
2135
+ // Based on the results of interface, component, and event looks up, add all the methods to the completions
2136
+ for (const method of methods) {
2137
+ completions.set(`method-${method.name}`, {
2138
+ label: method.name,
2139
+ type: 'method',
2140
+ detail: (_h = method.description) !== null && _h !== void 0 ? _h : '',
2141
+ sortText: `${CompletionSortTier.Method}${method.name}`
2142
+ });
2143
+ }
2100
2144
  }
2101
2145
  // Add the global functions to the completions results
2102
2146
  if (supplyLocalScopeCompletions) {
@@ -2104,8 +2148,8 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2104
2148
  completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
2105
2149
  label: globalCallable.name,
2106
2150
  type: 'function',
2107
- detail: (_j = (_h = globalCallable.shortDescription) !== null && _h !== void 0 ? _h : globalCallable.documentation) !== null && _j !== void 0 ? _j : '',
2108
- sortText: '000000'
2151
+ detail: (_k = (_j = globalCallable.shortDescription) !== null && _j !== void 0 ? _j : globalCallable.documentation) !== null && _k !== void 0 ? _k : '',
2152
+ sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2109
2153
  });
2110
2154
  }
2111
2155
  const frame = this.rokuAdapter.getStackFrameById(args.frameId);
@@ -2116,7 +2160,7 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2116
2160
  completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
2117
2161
  label: scopeFunction.name,
2118
2162
  type: scopeFunction.completionItemKind,
2119
- sortText: '000000'
2163
+ sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2120
2164
  });
2121
2165
  }
2122
2166
  }
@@ -2127,8 +2171,14 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2127
2171
  }
2128
2172
  }
2129
2173
  }
2130
- // this.sendEvent(new LogOutputEvent(`text: ${args.text} | completions: ${completions.map(v => v.label).join(', ')}`));
2131
- // this.sendEvent(new OutputEvent(`text: ${args.text} | completions: ${completions.map(v => v.label).join(', ')}\n`, 'stderr'));
2174
+ // Apply the default replacement span to every completion that didn't already set its own (bracket
2175
+ // rewrites above use an extended range that also consumes the preceding `.`).
2176
+ for (const target of completions.values()) {
2177
+ if (target.start === undefined) {
2178
+ target.start = replaceRange.start;
2179
+ target.length = replaceRange.length;
2180
+ }
2181
+ }
2132
2182
  response.body = {
2133
2183
  targets: [...completions.values()]
2134
2184
  };
@@ -2144,32 +2194,76 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2144
2194
  * Gets the closest completion details the incoming completion request.
2145
2195
  */
2146
2196
  getClosestCompletionDetails(args) {
2197
+ var _a;
2147
2198
  const incomingText = args.text;
2148
2199
  const lines = incomingText.split('\n');
2149
2200
  let lineNumber = this.toDebuggerLine(args.line, 0);
2150
2201
  let column = this.toDebuggerColumn(args.column);
2151
- const targetLine = lines[lineNumber];
2152
- let variablePathString = '';
2153
- let i = column - 1;
2202
+ const targetLine = (_a = lines[lineNumber]) !== null && _a !== void 0 ? _a : '';
2203
+ const cursorIndex = column - 1;
2154
2204
  const variableChars = /[a-z0-9_\.]/i;
2155
- // If the character at immediate to the right of the cursor is a variable character, then we are not at the end of the variable path.
2156
- if (targetLine.length - 1 > i && variableChars.test(targetLine[i + 1])) {
2205
+ // If the character immediately to the right of the cursor is a variable character, then we are
2206
+ // in the middle of a token and should not supply completions yet.
2207
+ if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
2157
2208
  return undefined;
2158
2209
  }
2159
- // Find the start of the variable path by looking for the first non-alphanumeric or non_underscore character before the cursor
2160
- while (i >= 0 && (variableChars.test(targetLine[i]))) {
2161
- i--;
2210
+ // Determine where the expression we want to complete ends, and whether we are completing the
2211
+ // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
2212
+ // (ex: `m["fo`) are both treated as member access on the parent expression.
2213
+ let endColumn = column;
2214
+ let isMemberAccess = false;
2215
+ //when set (including ''), the user is completing a string key; the value is the text to append to
2216
+ //close the access (ex: `"]`), empty when a closing bracket is already present
2217
+ let stringKeyClosing;
2218
+ const openBracket = this.findUnclosedOpener(targetLine, column);
2219
+ if ((openBracket === null || openBracket === void 0 ? void 0 : openBracket.char) === '[') {
2220
+ // find the opening quote (skipping any whitespace after the `[`)
2221
+ let quoteIndex = openBracket.index + 1;
2222
+ while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
2223
+ quoteIndex++;
2224
+ }
2225
+ const quote = targetLine[quoteIndex];
2226
+ if (quote === '"' || quote === `'`) {
2227
+ // The user is typing a string key, so complete the keys of the expression before the `[`
2228
+ endColumn = openBracket.index;
2229
+ isMemberAccess = true;
2230
+ // close the string and bracket only when there is nothing meaningful after the cursor
2231
+ stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
2232
+ }
2233
+ }
2234
+ // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2235
+ // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2236
+ let startIndex = endColumn - 1;
2237
+ let bracketDepth = 0;
2238
+ while (startIndex >= 0) {
2239
+ const char = targetLine[startIndex];
2240
+ if (char === ']') {
2241
+ bracketDepth++;
2242
+ }
2243
+ else if (char === '[') {
2244
+ if (bracketDepth === 0) {
2245
+ // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2246
+ break;
2247
+ }
2248
+ bracketDepth--;
2249
+ }
2250
+ else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
2251
+ break;
2252
+ }
2253
+ startIndex--;
2162
2254
  }
2163
- // Pull the variable path string from the line
2164
- variablePathString = targetLine.slice(i + 1, column);
2165
- // Attempted dot access something unexpected
2166
- // Example: `getPerson().name` where `getPerson()` is not a valid variable
2167
- // and results in `.name` being the variable path string
2255
+ const variablePathString = targetLine.slice(startIndex + 1, endColumn);
2256
+ // Attempted dot access on something unexpected.
2257
+ // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2258
+ // which leaves `.name` as the variable path string.
2168
2259
  if (variablePathString.startsWith('.')) {
2169
2260
  return undefined;
2170
2261
  }
2262
+ if (variablePathString.endsWith('.')) {
2263
+ isMemberAccess = true;
2264
+ }
2171
2265
  // Get the variable path from the text
2172
- let variablePath = [];
2266
+ let variablePath;
2173
2267
  if (!variablePathString.trim()) {
2174
2268
  // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2175
2269
  variablePath = [''];
@@ -2185,28 +2279,170 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2185
2279
  if (!variablePath) {
2186
2280
  return undefined;
2187
2281
  }
2188
- let parentVariablePath;
2189
- // If the last character is a period, then pull completions for the parent variable before the period
2190
- if (variablePathString.endsWith('.')) {
2191
- parentVariablePath = variablePath;
2192
- }
2193
- else {
2194
- // Otherwise, pull completions for the parent variable
2195
- parentVariablePath = variablePath.slice(0, variablePath.length - 1);
2196
- }
2197
- // If the parent variable path is empty or an empty string, then we are looking up the local scope variables and global functions
2282
+ // For member access we complete the members of the full expression. Otherwise we complete the
2283
+ // siblings of the final (partial) segment, so drop it to get the parent.
2284
+ let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
2285
+ // An empty parent path means we are looking up the local scope variables and global functions
2198
2286
  if (parentVariablePath.length === 0) {
2199
2287
  parentVariablePath = [''];
2200
2288
  }
2201
- return { parentVariablePath: parentVariablePath };
2289
+ const result = { parentVariablePath: parentVariablePath };
2290
+ // Only attach the string-key context when we actually resolved a parent object to complete keys on
2291
+ if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
2292
+ result.stringKeyClosing = stringKeyClosing;
2293
+ }
2294
+ return result;
2295
+ }
2296
+ /**
2297
+ * Compute the span of input text that a completion replaces: the run of identifier characters
2298
+ * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2299
+ * typing past the first character.
2300
+ *
2301
+ * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2302
+ * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2303
+ * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2304
+ * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2305
+ */
2306
+ getCompletionReplaceRange(args) {
2307
+ var _a;
2308
+ const lines = args.text.split('\n');
2309
+ const lineNumber = this.toDebuggerLine(args.line, 0);
2310
+ const cursorOffset = this.toDebuggerColumn(args.column);
2311
+ const targetLine = (_a = lines[lineNumber]) !== null && _a !== void 0 ? _a : '';
2312
+ const identifierChars = /[a-z0-9_]/i;
2313
+ let wordStart = cursorOffset;
2314
+ while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
2315
+ wordStart--;
2316
+ }
2317
+ return {
2318
+ start: wordStart,
2319
+ length: cursorOffset - wordStart
2320
+ };
2321
+ }
2322
+ /**
2323
+ * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2324
+ * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2325
+ */
2326
+ findUnclosedOpener(line, column) {
2327
+ let depth = 0;
2328
+ for (let i = column - 1; i >= 0; i--) {
2329
+ const char = line[i];
2330
+ if (char === ')' || char === ']' || char === '}') {
2331
+ depth++;
2332
+ }
2333
+ else if (char === '(' || char === '[' || char === '{') {
2334
+ if (depth === 0) {
2335
+ return { index: i, char: char };
2336
+ }
2337
+ depth--;
2338
+ }
2339
+ }
2340
+ return undefined;
2341
+ }
2342
+ /**
2343
+ * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2344
+ * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2345
+ * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2346
+ */
2347
+ async resolveCompletionParentVariable(parentVariablePath, frameId) {
2348
+ // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2349
+ // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2350
+ const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
2351
+ if (isLocalScope) {
2352
+ const localsScope = this.getOrCreateLocalsScope(frameId);
2353
+ if (!localsScope.isResolved) {
2354
+ try {
2355
+ await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference });
2356
+ }
2357
+ catch (error) {
2358
+ this.logger.debug('Could not populate locals for completions', error, { frameId });
2359
+ }
2360
+ }
2361
+ }
2362
+ const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
2363
+ if (inMemory && inMemory.childVariables.length > 0) {
2364
+ return inMemory;
2365
+ }
2366
+ // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
2367
+ // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
2368
+ const expression = this.buildVariableExpression(parentVariablePath);
2369
+ const cacheKey = `${frameId}:${expression}`;
2370
+ if (this.completionParentVariableCache.has(cacheKey)) {
2371
+ return this.completionParentVariableCache.get(cacheKey);
2372
+ }
2373
+ let parentVariable;
2374
+ try {
2375
+ let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
2376
+ let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
2377
+ parentVariable = await this.getVariableFromResult(result, frameId);
2378
+ }
2379
+ catch (error) {
2380
+ // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2381
+ this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
2382
+ parentVariable = undefined;
2383
+ }
2384
+ this.completionParentVariableCache.set(cacheKey, parentVariable);
2385
+ return parentVariable;
2386
+ }
2387
+ /**
2388
+ * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2389
+ * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2390
+ * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2391
+ * use dot access. This keeps array indices and string keys correct through the device lookup.
2392
+ */
2393
+ buildVariableExpression(segments) {
2394
+ return segments.reduce((expression, segment, index) => {
2395
+ if (index === 0) {
2396
+ return segment;
2397
+ }
2398
+ //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2399
+ //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2400
+ if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
2401
+ return `${expression}[${segment}]`;
2402
+ }
2403
+ if (/^[0-9]+$/.test(segment)) {
2404
+ return `${expression}[${segment}]`;
2405
+ }
2406
+ if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
2407
+ return `${expression}.${segment}`;
2408
+ }
2409
+ return `${expression}["${segment.replace(/"/g, '""')}"]`;
2410
+ }, '');
2411
+ }
2412
+ /**
2413
+ * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
2414
+ * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
2415
+ * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
2416
+ * the user typed (ex: `topRef` matching the cached `topref`).
2417
+ */
2418
+ normalizeVariableName(name) {
2419
+ let value = name !== null && name !== void 0 ? name : '';
2420
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
2421
+ value = value.slice(1, -1).replace(/""/g, '"');
2422
+ }
2423
+ return value.toLowerCase();
2424
+ }
2425
+ /**
2426
+ * Resolve a variable path against the current frame's local scope. The first path segment is matched
2427
+ * against the frame's locals (not the global pool of every materialized variable), then we walk down
2428
+ * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
2429
+ */
2430
+ findFrameVariableByPath(path, frameId) {
2431
+ var _a;
2432
+ const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
2433
+ if (path.length === 1 && path[0] === '') {
2434
+ return localsContainer;
2435
+ }
2436
+ return this.findVariableByPath((_a = localsContainer === null || localsContainer === void 0 ? void 0 : localsContainer.childVariables) !== null && _a !== void 0 ? _a : [], path, frameId);
2202
2437
  }
2203
2438
  findVariableByPath(variables, path, frameId) {
2204
2439
  var _a;
2205
2440
  let current = null;
2206
2441
  for (const name of path) {
2207
- // Find the object matching the current name in the data
2442
+ const normalizedName = this.normalizeVariableName(name);
2443
+ // Find the object matching the current name in the data (case-insensitive, per BrightScript)
2208
2444
  current = (_a = (Array.isArray(variables) ? variables : current === null || current === void 0 ? void 0 : current.childVariables)) === null || _a === void 0 ? void 0 : _a.find(obj => {
2209
- return obj.name === name && obj.frameId === frameId;
2445
+ return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
2210
2446
  });
2211
2447
  // If no match is found, return null
2212
2448
  if (!current) {
@@ -2597,6 +2833,7 @@ class BrightScriptDebugSession extends debugadapter_1.LoggingDebugSession {
2597
2833
  clearState() {
2598
2834
  //erase all cached variables
2599
2835
  this.variables = {};
2836
+ this.completionParentVariableCache.clear();
2600
2837
  }
2601
2838
  /**
2602
2839
  * Sends a launch progress event to the client if the client supports progress reporting.