| 259 | * @returns The completable token and its start position, or null if not found |
| 260 | */ |
| 261 | export function extractCompletionToken(text: string, cursorPos: number, includeAtSymbol = false): { |
| 262 | token: string; |
| 263 | startPos: number; |
| 264 | isQuoted?: boolean; |
| 265 | } | null { |
| 266 | // Empty input check |
| 267 | if (!text) return null; |
| 268 | |
| 269 | // Get text up to cursor |
| 270 | const textBeforeCursor = text.substring(0, cursorPos); |
| 271 | |
| 272 | // Check for quoted @ mention first (e.g., @"my file with spaces") |
| 273 | if (includeAtSymbol) { |
| 274 | const quotedAtRegex = /@"([^"]*)"?$/; |
| 275 | const quotedMatch = textBeforeCursor.match(quotedAtRegex); |
| 276 | if (quotedMatch && quotedMatch.index !== undefined) { |
| 277 | // Include any remaining quoted content after cursor until closing quote or end |
| 278 | const textAfterCursor = text.substring(cursorPos); |
| 279 | const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/); |
| 280 | const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : ''; |
| 281 | return { |
| 282 | token: quotedMatch[0] + quotedSuffix, |
| 283 | startPos: quotedMatch.index, |
| 284 | isQuoted: true |
| 285 | }; |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan |
| 290 | if (includeAtSymbol) { |
| 291 | const atIdx = textBeforeCursor.lastIndexOf('@'); |
| 292 | if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) { |
| 293 | const fromAt = textBeforeCursor.substring(atIdx); |
| 294 | const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE); |
| 295 | if (atHeadMatch && atHeadMatch[0].length === fromAt.length) { |
| 296 | const textAfterCursor = text.substring(cursorPos); |
| 297 | const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); |
| 298 | const tokenSuffix = afterMatch ? afterMatch[0] : ''; |
| 299 | return { |
| 300 | token: atHeadMatch[0] + tokenSuffix, |
| 301 | startPos: atIdx, |
| 302 | isQuoted: false |
| 303 | }; |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // Non-@ token or cursor outside @ token — use $ anchor on (short) tail |
| 309 | const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE; |
| 310 | const match = textBeforeCursor.match(tokenRegex); |
| 311 | if (!match || match.index === undefined) { |
| 312 | return null; |
| 313 | } |
| 314 | |
| 315 | // Check if cursor is in the MIDDLE of a token (more word characters after cursor) |
| 316 | // If so, extend the token to include all characters until whitespace or end of string |
| 317 | const textAfterCursor = text.substring(cursorPos); |
| 318 | const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); |