/ tools / LSPTool / symbolContext.ts
symbolContext.ts
 1  import { logForDebugging } from '../../utils/debug.js'
 2  import { truncate } from '../../utils/format.js'
 3  import { getFsImplementation } from '../../utils/fsOperations.js'
 4  import { expandPath } from '../../utils/path.js'
 5  
 6  const MAX_READ_BYTES = 64 * 1024
 7  
 8  /**
 9   * Extracts the symbol/word at a specific position in a file.
10   * Used to show context in tool use messages.
11   *
12   * @param filePath - The file path (absolute or relative)
13   * @param line - 0-indexed line number
14   * @param character - 0-indexed character position on the line
15   *
16   * Note: This uses synchronous file I/O because it is called from
17   * renderToolUseMessage (a synchronous React render function). The read is
18   * wrapped in try/catch so ENOENT and other errors fall back gracefully.
19   * @returns The symbol at that position, or null if extraction fails
20   */
21  export function getSymbolAtPosition(
22    filePath: string,
23    line: number,
24    character: number,
25  ): string | null {
26    try {
27      const fs = getFsImplementation()
28      const absolutePath = expandPath(filePath)
29  
30      // Read only the first 64KB instead of the whole file. Most LSP hover/goto
31      // targets are near recent edits; 64KB covers ~1000 lines of typical code.
32      // If the target line is past this window we fall back to null (the UI
33      // already handles that by showing `position: line:char`).
34      // eslint-disable-next-line custom-rules/no-sync-fs -- called from sync React render (renderToolUseMessage)
35      const { buffer, bytesRead } = fs.readSync(absolutePath, {
36        length: MAX_READ_BYTES,
37      })
38      const content = buffer.toString('utf-8', 0, bytesRead)
39      const lines = content.split('\n')
40  
41      if (line < 0 || line >= lines.length) {
42        return null
43      }
44      // If we filled the full buffer the file continues past our window,
45      // so the last split element may be truncated mid-line.
46      if (bytesRead === MAX_READ_BYTES && line === lines.length - 1) {
47        return null
48      }
49  
50      const lineContent = lines[line]
51      if (!lineContent || character < 0 || character >= lineContent.length) {
52        return null
53      }
54  
55      // Extract the word/symbol at the character position
56      // Pattern matches:
57      // - Standard identifiers: alphanumeric + underscore + dollar
58      // - Rust lifetimes: 'a, 'static
59      // - Rust macros: macro_name!
60      // - Operators and special symbols: +, -, *, etc.
61      // This is more inclusive to handle various programming languages
62      const symbolPattern = /[\w$'!]+|[+\-*/%&|^~<>=]+/g
63      let match: RegExpExecArray | null
64  
65      while ((match = symbolPattern.exec(lineContent)) !== null) {
66        const start = match.index
67        const end = start + match[0].length
68  
69        // Check if the character position falls within this match
70        if (character >= start && character < end) {
71          const symbol = match[0]
72          // Limit length to 30 characters to avoid overly long symbols
73          return truncate(symbol, 30)
74        }
75      }
76  
77      return null
78    } catch (error) {
79      // Log unexpected errors for debugging (permission issues, encoding problems, etc.)
80      // Use logForDebugging since this is a display enhancement, not a critical error
81      if (error instanceof Error) {
82        logForDebugging(
83          `Symbol extraction failed for ${filePath}:${line}:${character}: ${error.message}`,
84          { level: 'warn' },
85        )
86      }
87      // Still return null for graceful fallback to position display
88      return null
89    }
90  }