|
| 1 | +/* -------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All Rights Reserved. |
| 3 | + * See 'LICENSE' in the project root for license information. |
| 4 | + * ------------------------------------------------------------------------------------------ */ |
| 5 | + |
| 6 | +// The column range and text of the expression a debug data-tip should evaluate. |
| 7 | +export interface EvaluatableExpressionInfo { |
| 8 | + readonly startColumn: number; |
| 9 | + readonly endColumn: number; |
| 10 | + readonly expression: string; |
| 11 | +} |
| 12 | + |
| 13 | +const wordChar: RegExp = /[\p{L}\p{N}_]/u; |
| 14 | + |
| 15 | +function isWord(ch: string | undefined): boolean { |
| 16 | + return ch !== undefined && wordChar.test(ch); |
| 17 | +} |
| 18 | + |
| 19 | +// Index just past the `]` that closes the `[` at `open`, or -1 if it is unbalanced. |
| 20 | +function matchingClose(line: string, open: number): number { |
| 21 | + let depth: number = 0; |
| 22 | + for (let j: number = open; j < line.length; j++) { |
| 23 | + if (line[j] === '[') { |
| 24 | + depth++; |
| 25 | + } else if (line[j] === ']') { |
| 26 | + depth--; |
| 27 | + if (depth === 0) { |
| 28 | + return j + 1; |
| 29 | + } |
| 30 | + } |
| 31 | + } |
| 32 | + return -1; |
| 33 | +} |
| 34 | + |
| 35 | +// Index of the `[` that opens the `]` at `close`, or -1 if it is unbalanced. |
| 36 | +function matchingOpen(line: string, close: number): number { |
| 37 | + let depth: number = 0; |
| 38 | + for (let j: number = close; j >= 0; j--) { |
| 39 | + if (line[j] === ']') { |
| 40 | + depth++; |
| 41 | + } else if (line[j] === '[') { |
| 42 | + depth--; |
| 43 | + if (depth === 0) { |
| 44 | + return j; |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + return -1; |
| 49 | +} |
| 50 | + |
| 51 | +// Start of the access chain that the subscript opening at `open` applies to, without crossing |
| 52 | +// `exprStart` or an enclosing (still-open) `[`. |
| 53 | +function primaryStart(line: string, open: number, exprStart: number): number { |
| 54 | + let s: number = open; |
| 55 | + while (s > exprStart) { |
| 56 | + const prev: string = line[s - 1]; |
| 57 | + if (isWord(prev)) { |
| 58 | + while (s > exprStart && isWord(line[s - 1])) { |
| 59 | + s--; |
| 60 | + } |
| 61 | + } else if (prev === '.') { |
| 62 | + s--; |
| 63 | + } else if (prev === '>' && line[s - 2] === '-') { |
| 64 | + s -= 2; |
| 65 | + } else if (prev === ':' && line[s - 2] === ':') { |
| 66 | + s -= 2; |
| 67 | + } else if (prev === ']') { |
| 68 | + const open2: number = matchingOpen(line, s - 1); |
| 69 | + if (open2 < exprStart) { |
| 70 | + break; |
| 71 | + } |
| 72 | + s = open2; |
| 73 | + } else { |
| 74 | + break; |
| 75 | + } |
| 76 | + } |
| 77 | + return s; |
| 78 | +} |
| 79 | + |
| 80 | +// Computes the expression a debug data-tip should evaluate for the token at `character` in `line`, |
| 81 | +// or undefined when the cursor is not on an expression token. |
| 82 | +// |
| 83 | +// Registering an EvaluatableExpressionProvider replaces VS Code's built-in data-tip expression |
| 84 | +// detection, so this reproduces that detection for ordinary tokens and additionally resolves access |
| 85 | +// chains involving a leading `*`/`&` or array subscripts, which the built-in detection mishandles: |
| 86 | +// - A leading `*` is kept only when hovering the final segment of the chain (the value actually |
| 87 | +// dereferenced, e.g. `*a.b.c`); on any interior segment it is dropped, so hovering `b` in |
| 88 | +// `*a.b.c` gives `a.b` and hovering `b` in `*a.b[i]` gives `a.b` (not `*a.b`, the dereferenced |
| 89 | +// struct/array base). A leading `&` is always dropped so the hovered variable shows its value |
| 90 | +// rather than its address. |
| 91 | +// - Array subscripts are part of the chain, including nested ones like `a[b[i]]`; hovering `c` in |
| 92 | +// `a.b[i].c` evaluates `a.b[i].c` rather than a fragment after the `]`, hovering a subscript |
| 93 | +// bracket evaluates the indexed element, and hovering the index evaluates it on its own. |
| 94 | +// |
| 95 | +// This has no vscode dependency so it can be unit tested directly. |
| 96 | +export function computeEvaluatableExpression(line: string, character: number): EvaluatableExpressionInfo | undefined { |
| 97 | + // Find the access-chain token containing the cursor: an optional leading run of `*`/`&`, then a |
| 98 | + // chain of identifiers, `.`, `->`, `::` and balanced `[...]` subscripts. Brackets are matched by |
| 99 | + // depth so nested subscripts stay in one token. The cursor is matched with an inclusive end so a |
| 100 | + // token is selected when the cursor is at its trailing edge (VS Code's built-in does the same). |
| 101 | + let tokenStart: number = -1; |
| 102 | + let tokenEnd: number = -1; |
| 103 | + const n: number = line.length; |
| 104 | + let i: number = 0; |
| 105 | + while (i < n) { |
| 106 | + const start: number = i; |
| 107 | + while (i < n && (line[i] === '*' || line[i] === '&')) { |
| 108 | + i++; |
| 109 | + } |
| 110 | + let chained: boolean = false; |
| 111 | + let advanced: boolean = true; |
| 112 | + while (i < n && advanced) { |
| 113 | + const c: string = line[i]; |
| 114 | + if (isWord(c)) { |
| 115 | + while (i < n && isWord(line[i])) { |
| 116 | + i++; |
| 117 | + } |
| 118 | + chained = true; |
| 119 | + } else if (c === '.') { |
| 120 | + i++; |
| 121 | + chained = true; |
| 122 | + } else if (c === '-' && line[i + 1] === '>') { |
| 123 | + i += 2; |
| 124 | + chained = true; |
| 125 | + } else if (c === ':' && line[i + 1] === ':') { |
| 126 | + i += 2; |
| 127 | + chained = true; |
| 128 | + } else if (c === '[') { |
| 129 | + const close: number = matchingClose(line, i); |
| 130 | + if (close === -1) { |
| 131 | + advanced = false; |
| 132 | + } else { |
| 133 | + i = close; |
| 134 | + chained = true; |
| 135 | + } |
| 136 | + } else { |
| 137 | + advanced = false; |
| 138 | + } |
| 139 | + } |
| 140 | + if (chained && start <= character && character <= i) { |
| 141 | + tokenStart = start; |
| 142 | + tokenEnd = i; |
| 143 | + break; |
| 144 | + } |
| 145 | + i = chained && i > start ? i : start + 1; |
| 146 | + } |
| 147 | + if (tokenStart === -1) { |
| 148 | + return undefined; |
| 149 | + } |
| 150 | + |
| 151 | + const leadingMatch: RegExpMatchArray | null = line.substring(tokenStart, tokenEnd).match(/^[*&]+/u); |
| 152 | + const leading: string | null = leadingMatch !== null ? leadingMatch[0] : null; |
| 153 | + const exprStart: number = tokenStart + (leading !== null ? leading.length : 0); |
| 154 | + |
| 155 | + // A chain can begin with `.` or `->` when its head was skipped (e.g. a call: `foo().bar` leaves |
| 156 | + // `.bar`). Such a fragment is not a valid expression, so decline it. |
| 157 | + if (line[exprStart] === '.' || (line[exprStart] === '-' && line[exprStart + 1] === '>')) { |
| 158 | + return undefined; |
| 159 | + } |
| 160 | + |
| 161 | + // On a subscript bracket, evaluate the indexed element: the subscripted primary through that |
| 162 | + // subscript, without the leading `*`/`&`. |
| 163 | + const cursorChar: string = line.charAt(character); |
| 164 | + if (cursorChar === '[' || cursorChar === ']') { |
| 165 | + const open: number = cursorChar === '[' ? character : matchingOpen(line, character); |
| 166 | + const close: number = cursorChar === '[' ? matchingClose(line, character) : character + 1; |
| 167 | + if (open !== -1 && close !== -1) { |
| 168 | + let startColumn: number = Math.max(primaryStart(line, open, exprStart), exprStart); |
| 169 | + // Keep a leading `*` when the subscript is the final segment (the dereferenced |
| 170 | + // element, e.g. `*a.b[i]`); a leading `&`, or an interior subscript, drops it. |
| 171 | + if (close === tokenEnd && startColumn === exprStart && leading !== null && /^\*+$/u.test(leading)) { |
| 172 | + startColumn = tokenStart; |
| 173 | + } |
| 174 | + return { startColumn, endColumn: close, expression: line.substring(startColumn, close) }; |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + // Locate the identifier under the cursor and the offset just past it. |
| 179 | + let clipEnd: number = tokenEnd; |
| 180 | + let wordStart: number = tokenStart; |
| 181 | + let word: string = ''; |
| 182 | + const wordRegExp: RegExp = /[\p{L}\p{N}_]+/gu; |
| 183 | + const tokenText: string = line.substring(tokenStart, tokenEnd); |
| 184 | + for (let w: RegExpExecArray | null = wordRegExp.exec(tokenText); w !== null; w = wordRegExp.exec(tokenText)) { |
| 185 | + clipEnd = tokenStart + w.index + w[0].length; |
| 186 | + wordStart = tokenStart + w.index; |
| 187 | + word = w[0]; |
| 188 | + if (clipEnd >= character) { |
| 189 | + break; |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + // An identifier inside a `[...]` is the index; it is evaluated on its own. Inside `[...]` the |
| 194 | + // chain also spans operators and whitespace (e.g. `a[i + j]`), so only return the identifier |
| 195 | + // when the cursor is actually on it; other positions are not tokens. |
| 196 | + let depth: number = 0; |
| 197 | + for (let k: number = tokenStart; k < character; k++) { |
| 198 | + if (line[k] === '[') { |
| 199 | + depth++; |
| 200 | + } else if (line[k] === ']') { |
| 201 | + depth--; |
| 202 | + } |
| 203 | + } |
| 204 | + if (depth > 0) { |
| 205 | + if (character < wordStart || character >= clipEnd) { |
| 206 | + return undefined; |
| 207 | + } |
| 208 | + return { startColumn: wordStart, endColumn: clipEnd, expression: word }; |
| 209 | + } |
| 210 | + |
| 211 | + // Past the last identifier but still on the token's trailing `]` (or its inclusive trailing |
| 212 | + // edge), with no identifier left between the cursor and the end: evaluate the indexed |
| 213 | + // element, like hovering that closing bracket, so the clip never cuts a subscript in half. |
| 214 | + if (line.charAt(tokenEnd - 1) === ']' && !/[\p{L}\p{N}_]/u.test(line.substring(character, tokenEnd))) { |
| 215 | + const open: number = matchingOpen(line, tokenEnd - 1); |
| 216 | + if (open !== -1) { |
| 217 | + let startColumn: number = Math.max(primaryStart(line, open, exprStart), exprStart); |
| 218 | + if (startColumn === exprStart && leading !== null && /^\*+$/u.test(leading)) { |
| 219 | + startColumn = tokenStart; |
| 220 | + } |
| 221 | + return { startColumn, endColumn: tokenEnd, expression: line.substring(startColumn, tokenEnd) }; |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + // The leading `*`/`&` belongs to the final segment of the chain. A `*` is kept only when the |
| 226 | + // cursor is on that final segment (the value actually dereferenced, e.g. `*a.b.c`). On any |
| 227 | + // interior segment it is dropped, since `*a.b` would dereference the struct `a.b` and `*a.b[i]` |
| 228 | + // the array base rather than the indexed element. A leading `&` is always dropped so hovering |
| 229 | + // the variable shows its value, not its address. |
| 230 | + const keepLeading: boolean = leading !== null && clipEnd >= tokenEnd && /^\*+$/u.test(leading); |
| 231 | + if (!keepLeading) { |
| 232 | + return { startColumn: exprStart, endColumn: clipEnd, expression: line.substring(exprStart, clipEnd) }; |
| 233 | + } |
| 234 | + return { startColumn: tokenStart, endColumn: clipEnd, expression: line.substring(tokenStart, clipEnd) }; |
| 235 | +} |
0 commit comments