@comfanion/usethis_search 0.2.0-dev.0 → 3.0.0-dev.1

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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * LSPAnalyzer — extracts code relations via Language Server Protocol.
3
+ *
4
+ * Uses LSPClient (lsp-client.ts) to communicate with real language servers
5
+ * (typescript-language-server, pylsp, gopls, etc.).
6
+ *
7
+ * Strategy: LSP-first with graceful degradation.
8
+ * - If LSP server binary not found → isAvailable() returns false → GraphBuilder uses regex fallback
9
+ * - If LSP server crashes or times out during analysis → analyzeFile() returns [] → GraphBuilder uses regex
10
+ * - Timeout: 5 seconds per file (configurable)
11
+ */
12
+
13
+ import path from "path"
14
+ import fs from "fs/promises"
15
+ import { ChunkWithId } from "../graph-builder"
16
+ import { LSPClient, LSPSymbolInformation, SymbolKind } from "./lsp-client"
17
+
18
+ export interface Relation {
19
+ from: string
20
+ to: string
21
+ predicate: string
22
+ weight: number
23
+ source: "lsp"
24
+ line?: number
25
+ }
26
+
27
+ /** Language ID mapping from file extension. */
28
+ const EXT_TO_LANGUAGE: Record<string, string> = {
29
+ ts: "typescript",
30
+ js: "javascript",
31
+ tsx: "typescriptreact",
32
+ jsx: "javascriptreact",
33
+ py: "python",
34
+ go: "go",
35
+ rs: "rust",
36
+ java: "java",
37
+ cpp: "cpp",
38
+ c: "c",
39
+ cs: "csharp",
40
+ }
41
+
42
+ /** Symbol kinds that represent types (classes, interfaces) — check implementations. */
43
+ const TYPE_SYMBOL_KINDS = new Set([
44
+ SymbolKind.Class,
45
+ SymbolKind.Interface,
46
+ ])
47
+
48
+ /** Symbol kinds worth analyzing for references/definitions. */
49
+ const ANALYZABLE_SYMBOL_KINDS = new Set([
50
+ SymbolKind.Class,
51
+ SymbolKind.Interface,
52
+ SymbolKind.Function,
53
+ SymbolKind.Method,
54
+ SymbolKind.Variable,
55
+ SymbolKind.Constant,
56
+ SymbolKind.Enum,
57
+ ])
58
+
59
+ export class LSPAnalyzer {
60
+ private client: LSPClient | null = null
61
+ private currentLanguage: string | null = null
62
+ private filesOpened = 0
63
+ private readonly timeoutMs: number
64
+
65
+ constructor(
66
+ private projectRoot?: string,
67
+ timeoutMs?: number,
68
+ ) {
69
+ this.timeoutMs = timeoutMs ?? 5000
70
+ }
71
+
72
+ // ---- public API ---------------------------------------------------------
73
+
74
+ /** Check if an LSP server is available for this file's language. */
75
+ async isAvailable(filePath: string): Promise<boolean> {
76
+ try {
77
+ const language = this.getLanguage(filePath)
78
+ if (!language) return false
79
+ return LSPClient.isAvailable(language)
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Analyze a file using LSP and return relations.
87
+ *
88
+ * Flow:
89
+ * 1. Start/reuse server for the file's language
90
+ * 2. Open document
91
+ * 3. Get document symbols (functions, classes, interfaces)
92
+ * 4. For each symbol: get definitions, references, implementations
93
+ * 5. Map LSP locations → chunk IDs → Relation objects
94
+ * 6. Close document
95
+ */
96
+ async analyzeFile(filePath: string, chunks: ChunkWithId[]): Promise<Relation[]> {
97
+ const language = this.getLanguage(filePath)
98
+ if (!language) return []
99
+
100
+ const root = this.projectRoot || process.cwd()
101
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath)
102
+
103
+ let content: string
104
+ try {
105
+ content = await fs.readFile(absPath, "utf-8")
106
+ } catch {
107
+ return []
108
+ }
109
+
110
+ // Ensure client is started for this language
111
+ try {
112
+ await this.ensureClient(language, root)
113
+ } catch {
114
+ return []
115
+ }
116
+
117
+ const client = this.client!
118
+ const relations: Relation[] = []
119
+
120
+ try {
121
+ // Open the document
122
+ await client.openDocument(filePath, content, language)
123
+ this.filesOpened++
124
+
125
+ // First file needs extra warmup for project loading / type graph build
126
+ if (this.filesOpened === 1) {
127
+ await new Promise(r => setTimeout(r, 2000))
128
+ } else {
129
+ await new Promise(r => setTimeout(r, 500))
130
+ }
131
+
132
+ // Get all symbols in the file
133
+ const rawSymbols = await client.documentSymbol(filePath)
134
+ const symbols = this.flattenSymbols(rawSymbols)
135
+
136
+ // Analyze each symbol
137
+ for (const sym of symbols) {
138
+ if (!ANALYZABLE_SYMBOL_KINDS.has(sym.kind)) continue
139
+
140
+ const fromChunkId = this.findChunkForPosition(chunks, sym.selectionRange.start.line)
141
+ if (!fromChunkId) continue
142
+
143
+ const selLine = sym.selectionRange.start.line
144
+ const selChar = sym.selectionRange.start.character
145
+
146
+ // --- implementations (for classes/interfaces) ---
147
+ if (TYPE_SYMBOL_KINDS.has(sym.kind)) {
148
+ try {
149
+ const impls = await client.implementation(filePath, selLine, selChar)
150
+ for (const impl of impls) {
151
+ const toChunkId = this.locationToChunkId(filePath, impl.uri, impl.range.start.line, root)
152
+ if (toChunkId && toChunkId !== fromChunkId) {
153
+ relations.push({
154
+ from: fromChunkId,
155
+ to: toChunkId,
156
+ predicate: "implements",
157
+ weight: 1.0,
158
+ source: "lsp",
159
+ line: selLine,
160
+ })
161
+ }
162
+ }
163
+ } catch { /* server may not support implementation */ }
164
+ }
165
+
166
+ // --- references ---
167
+ try {
168
+ const refs = await client.references(filePath, selLine, selChar)
169
+ for (const ref of refs) {
170
+ const toChunkId = this.locationToChunkId(filePath, ref.uri, ref.range.start.line, root)
171
+ if (toChunkId && toChunkId !== fromChunkId) {
172
+ relations.push({
173
+ from: toChunkId,
174
+ to: fromChunkId,
175
+ predicate: "used_by",
176
+ weight: 1.0,
177
+ source: "lsp",
178
+ line: ref.range.start.line,
179
+ })
180
+ }
181
+ }
182
+ } catch { /* server may not support references */ }
183
+
184
+ // --- definitions ---
185
+ try {
186
+ const defs = await client.definition(filePath, selLine, selChar)
187
+ for (const def of defs) {
188
+ const toChunkId = this.locationToChunkId(filePath, def.uri, def.range.start.line, root)
189
+ if (toChunkId && toChunkId !== fromChunkId) {
190
+ relations.push({
191
+ from: fromChunkId,
192
+ to: toChunkId,
193
+ predicate: "references",
194
+ weight: 1.0,
195
+ source: "lsp",
196
+ line: selLine,
197
+ })
198
+ }
199
+ }
200
+ } catch { /* server may not support definition */ }
201
+ }
202
+
203
+ await client.closeDocument(filePath)
204
+ } catch {
205
+ // Any error — return whatever we collected so far (or empty)
206
+ }
207
+
208
+ return this.deduplicateRelations(relations)
209
+ }
210
+
211
+ /** Stop the LSP server. Call when done indexing. */
212
+ async shutdown(): Promise<void> {
213
+ if (this.client) {
214
+ try { await this.client.stop() } catch { /* best effort */ }
215
+ this.client = null
216
+ this.currentLanguage = null
217
+ this.filesOpened = 0
218
+ }
219
+ }
220
+
221
+ // ---- internals ----------------------------------------------------------
222
+
223
+ private getLanguage(filePath: string): string | null {
224
+ const ext = filePath.split(".").pop()
225
+ if (!ext) return null
226
+ return EXT_TO_LANGUAGE[ext] || null
227
+ }
228
+
229
+ /** Start or reuse a client for the given language. */
230
+ private async ensureClient(language: string, root: string): Promise<void> {
231
+ // If we already have a client for a different language, shut it down
232
+ if (this.client && this.currentLanguage !== language) {
233
+ await this.shutdown()
234
+ }
235
+ if (!this.client) {
236
+ this.client = new LSPClient(root, this.timeoutMs)
237
+ await this.client.start(language)
238
+ this.currentLanguage = language
239
+ }
240
+ }
241
+
242
+ /** Flatten hierarchical DocumentSymbol tree into flat array. */
243
+ private flattenSymbols(symbols: LSPSymbolInformation[]): LSPSymbolInformation[] {
244
+ const result: LSPSymbolInformation[] = []
245
+ const walk = (list: LSPSymbolInformation[]) => {
246
+ for (const sym of list) {
247
+ result.push(sym)
248
+ if (sym.children?.length) walk(sym.children)
249
+ }
250
+ }
251
+ walk(symbols)
252
+ return result
253
+ }
254
+
255
+ /** Convert LSP location URI + line → chunk_id. */
256
+ private locationToChunkId(currentFile: string, uri: string, line: number, root: string): string | null {
257
+ // uri = file:///absolute/path/to/file.ts
258
+ const filePath = uri.startsWith("file://") ? uri.slice(7) : uri
259
+ const relPath = path.relative(root, filePath)
260
+
261
+ // Skip external files (node_modules, etc.)
262
+ if (relPath.startsWith("..") || relPath.includes("node_modules")) return null
263
+
264
+ const withoutExt = relPath.replace(/\.[^/.]+$/, "")
265
+ const normalized = withoutExt.replace(/[^a-zA-Z0-9]/g, "_")
266
+ // For cross-file references, point to chunk 0 (first chunk of target file)
267
+ // For same-file, we could be more precise but chunk 0 is sufficient for graph
268
+ return `chunk_${normalized}_0`
269
+ }
270
+
271
+ private findChunkForPosition(chunks: ChunkWithId[], line: number): string | null {
272
+ for (const chunk of chunks) {
273
+ if (chunk.start_line !== undefined && chunk.end_line !== undefined) {
274
+ if (line >= chunk.start_line && line <= chunk.end_line) {
275
+ return chunk.chunk_id
276
+ }
277
+ }
278
+ }
279
+ // Fallback: if no chunk matches by line, return first chunk
280
+ return chunks.length > 0 ? chunks[0].chunk_id : null
281
+ }
282
+
283
+ /** Remove duplicate relations (same from+to+predicate). */
284
+ private deduplicateRelations(relations: Relation[]): Relation[] {
285
+ const seen = new Set<string>()
286
+ return relations.filter(r => {
287
+ const key = `${r.from}|${r.to}|${r.predicate}`
288
+ if (seen.has(key)) return false
289
+ seen.add(key)
290
+ return true
291
+ })
292
+ }
293
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Lightweight LSP client over JSON-RPC / stdio.
3
+ *
4
+ * Spawns a language server as a child process, speaks the Language Server
5
+ * Protocol, and exposes high-level helpers used by LSPAnalyzer.
6
+ *
7
+ * Zero external dependencies — implements JSON-RPC framing inline.
8
+ */
9
+
10
+ import { spawn, ChildProcess } from "child_process"
11
+ import path from "path"
12
+ import fs from "fs"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // LSP types (minimal subset we need)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface LSPPosition {
19
+ line: number
20
+ character: number
21
+ }
22
+
23
+ export interface LSPRange {
24
+ start: LSPPosition
25
+ end: LSPPosition
26
+ }
27
+
28
+ export interface LSPLocation {
29
+ uri: string
30
+ range: LSPRange
31
+ }
32
+
33
+ export interface LSPSymbolInformation {
34
+ name: string
35
+ kind: number
36
+ range: LSPRange
37
+ selectionRange: LSPRange
38
+ children?: LSPSymbolInformation[]
39
+ }
40
+
41
+ // SymbolKind constants we care about
42
+ export const SymbolKind = {
43
+ File: 1,
44
+ Module: 2,
45
+ Namespace: 3,
46
+ Package: 4,
47
+ Class: 5,
48
+ Method: 6,
49
+ Property: 7,
50
+ Field: 8,
51
+ Constructor: 9,
52
+ Enum: 10,
53
+ Interface: 11,
54
+ Function: 12,
55
+ Variable: 13,
56
+ Constant: 14,
57
+ TypeParameter: 26,
58
+ } as const
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Server binary resolution
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface ServerConfig {
65
+ command: string
66
+ args: string[]
67
+ languages: string[]
68
+ }
69
+
70
+ const SERVER_CONFIGS: Record<string, ServerConfig> = {
71
+ typescript: {
72
+ command: "typescript-language-server",
73
+ args: ["--stdio"],
74
+ languages: ["typescript", "javascript", "typescriptreact", "javascriptreact"],
75
+ },
76
+ python: {
77
+ command: "pylsp",
78
+ args: [],
79
+ languages: ["python"],
80
+ },
81
+ go: {
82
+ command: "gopls",
83
+ args: ["serve"],
84
+ languages: ["go"],
85
+ },
86
+ rust: {
87
+ command: "rust-analyzer",
88
+ args: [],
89
+ languages: ["rust"],
90
+ },
91
+ }
92
+
93
+ function languageToServerId(language: string): string | null {
94
+ for (const [id, cfg] of Object.entries(SERVER_CONFIGS)) {
95
+ if (cfg.languages.includes(language)) return id
96
+ }
97
+ return null
98
+ }
99
+
100
+ function findBinary(name: string): string | null {
101
+ // 1. Check PATH via which-style lookup
102
+ const pathEnv = process.env.PATH || ""
103
+ const dirs = pathEnv.split(path.delimiter)
104
+ for (const dir of dirs) {
105
+ const full = path.join(dir, name)
106
+ try {
107
+ fs.accessSync(full, fs.constants.X_OK)
108
+ return full
109
+ } catch { /* not found */ }
110
+ }
111
+ return null
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // JSON-RPC framing
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function encodeMessage(body: object): Buffer {
119
+ const json = JSON.stringify(body)
120
+ const content = Buffer.from(json, "utf-8")
121
+ const header = `Content-Length: ${content.byteLength}\r\n\r\n`
122
+ return Buffer.concat([Buffer.from(header, "ascii"), content])
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // LSPClient
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export class LSPClient {
130
+ private proc: ChildProcess | null = null
131
+ private requestId = 0
132
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>()
133
+ private buffer = Buffer.alloc(0)
134
+ private initialized = false
135
+ private serverConfig: ServerConfig | null = null
136
+ private rootUri: string
137
+
138
+ constructor(
139
+ private projectRoot: string,
140
+ private timeoutMs: number = 10_000,
141
+ ) {
142
+ this.rootUri = `file://${this.projectRoot}`
143
+ }
144
+
145
+ // ---- lifecycle ----------------------------------------------------------
146
+
147
+ /** Check if a server binary exists for the given language. */
148
+ static isAvailable(language: string): boolean {
149
+ const serverId = languageToServerId(language)
150
+ if (!serverId) return false
151
+ const cfg = SERVER_CONFIGS[serverId]
152
+ return findBinary(cfg.command) !== null
153
+ }
154
+
155
+ /** Start the language server for `language`. Rejects if unavailable. */
156
+ async start(language: string): Promise<void> {
157
+ if (this.initialized) return
158
+
159
+ const serverId = languageToServerId(language)
160
+ if (!serverId) throw new Error(`No LSP server config for language: ${language}`)
161
+
162
+ const cfg = SERVER_CONFIGS[serverId]
163
+ const bin = findBinary(cfg.command)
164
+ if (!bin) throw new Error(`LSP server binary not found: ${cfg.command}`)
165
+
166
+ this.serverConfig = cfg
167
+
168
+ this.proc = spawn(bin, cfg.args, {
169
+ cwd: this.projectRoot,
170
+ stdio: ["pipe", "pipe", "pipe"],
171
+ env: { ...process.env },
172
+ })
173
+
174
+ this.proc.stdout!.on("data", (chunk: Buffer) => this.onData(chunk))
175
+ this.proc.stderr!.on("data", (chunk: Buffer) => {
176
+ // Silently consume stderr — language servers are chatty
177
+ })
178
+ this.proc.on("error", (err) => {
179
+ // Reject all pending
180
+ for (const p of this.pending.values()) p.reject(err)
181
+ this.pending.clear()
182
+ })
183
+ this.proc.on("exit", () => {
184
+ for (const p of this.pending.values()) p.reject(new Error("LSP server exited"))
185
+ this.pending.clear()
186
+ this.initialized = false
187
+ })
188
+
189
+ // LSP initialize handshake
190
+ const initResult = await this.sendRequest("initialize", {
191
+ processId: process.pid,
192
+ rootUri: this.rootUri,
193
+ capabilities: {
194
+ textDocument: {
195
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
196
+ definition: { linkSupport: false },
197
+ references: {},
198
+ implementation: {},
199
+ },
200
+ },
201
+ })
202
+
203
+ // Send initialized notification (no response expected)
204
+ this.sendNotification("initialized", {})
205
+ this.initialized = true
206
+ }
207
+
208
+ /** Shut down gracefully. */
209
+ async stop(): Promise<void> {
210
+ if (!this.proc || !this.initialized) return
211
+ try {
212
+ await this.sendRequest("shutdown", null)
213
+ this.sendNotification("exit", null)
214
+ } catch { /* best effort */ }
215
+ this.proc.kill()
216
+ this.proc = null
217
+ this.initialized = false
218
+ }
219
+
220
+ // ---- LSP helpers --------------------------------------------------------
221
+
222
+ async openDocument(filePath: string, content: string, languageId: string): Promise<void> {
223
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
224
+ this.sendNotification("textDocument/didOpen", {
225
+ textDocument: {
226
+ uri,
227
+ languageId,
228
+ version: 1,
229
+ text: content,
230
+ },
231
+ })
232
+ // Brief pause to let the notification reach the server.
233
+ // Callers that need full type info should wait separately.
234
+ await new Promise(r => setTimeout(r, 200))
235
+ }
236
+
237
+ async closeDocument(filePath: string): Promise<void> {
238
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
239
+ this.sendNotification("textDocument/didClose", {
240
+ textDocument: { uri },
241
+ })
242
+ }
243
+
244
+ async documentSymbol(filePath: string): Promise<LSPSymbolInformation[]> {
245
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
246
+ const result = await this.sendRequest("textDocument/documentSymbol", {
247
+ textDocument: { uri },
248
+ })
249
+ return result || []
250
+ }
251
+
252
+ async definition(filePath: string, line: number, character: number): Promise<LSPLocation[]> {
253
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
254
+ const result = await this.sendRequest("textDocument/definition", {
255
+ textDocument: { uri },
256
+ position: { line, character },
257
+ })
258
+ return this.normalizeLocations(result)
259
+ }
260
+
261
+ async references(filePath: string, line: number, character: number): Promise<LSPLocation[]> {
262
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
263
+ const result = await this.sendRequest("textDocument/references", {
264
+ textDocument: { uri },
265
+ position: { line, character },
266
+ context: { includeDeclaration: false },
267
+ })
268
+ return this.normalizeLocations(result)
269
+ }
270
+
271
+ async implementation(filePath: string, line: number, character: number): Promise<LSPLocation[]> {
272
+ const uri = `file://${path.resolve(this.projectRoot, filePath)}`
273
+ const result = await this.sendRequest("textDocument/implementation", {
274
+ textDocument: { uri },
275
+ position: { line, character },
276
+ })
277
+ return this.normalizeLocations(result)
278
+ }
279
+
280
+ // ---- transport ----------------------------------------------------------
281
+
282
+ private sendRequest(method: string, params: any): Promise<any> {
283
+ return new Promise((resolve, reject) => {
284
+ if (!this.proc?.stdin?.writable) {
285
+ return reject(new Error("LSP server not running"))
286
+ }
287
+ const id = ++this.requestId
288
+ const msg = { jsonrpc: "2.0", id, method, params }
289
+
290
+ const timer = setTimeout(() => {
291
+ this.pending.delete(id)
292
+ reject(new Error(`LSP request timed out: ${method} (${this.timeoutMs}ms)`))
293
+ }, this.timeoutMs)
294
+
295
+ this.pending.set(id, {
296
+ resolve: (v: any) => { clearTimeout(timer); resolve(v) },
297
+ reject: (e: Error) => { clearTimeout(timer); reject(e) },
298
+ })
299
+
300
+ this.proc.stdin.write(encodeMessage(msg))
301
+ })
302
+ }
303
+
304
+ private sendNotification(method: string, params: any): void {
305
+ if (!this.proc?.stdin?.writable) return
306
+ const msg = { jsonrpc: "2.0", method, params }
307
+ this.proc.stdin.write(encodeMessage(msg))
308
+ }
309
+
310
+ private onData(chunk: Buffer): void {
311
+ this.buffer = Buffer.concat([this.buffer, chunk])
312
+ this.processBuffer()
313
+ }
314
+
315
+ private processBuffer(): void {
316
+ while (true) {
317
+ // Look for Content-Length header
318
+ const headerEnd = this.buffer.indexOf("\r\n\r\n")
319
+ if (headerEnd === -1) break
320
+
321
+ const headerStr = this.buffer.subarray(0, headerEnd).toString("ascii")
322
+ const match = headerStr.match(/Content-Length:\s*(\d+)/i)
323
+ if (!match) {
324
+ // Corrupt header — skip past it
325
+ this.buffer = this.buffer.subarray(headerEnd + 4)
326
+ continue
327
+ }
328
+
329
+ const contentLength = parseInt(match[1], 10)
330
+ const bodyStart = headerEnd + 4
331
+ const bodyEnd = bodyStart + contentLength
332
+
333
+ if (this.buffer.length < bodyEnd) break // not enough data yet
334
+
335
+ const bodyStr = this.buffer.subarray(bodyStart, bodyEnd).toString("utf-8")
336
+ this.buffer = this.buffer.subarray(bodyEnd)
337
+
338
+ try {
339
+ const msg = JSON.parse(bodyStr)
340
+ this.handleMessage(msg)
341
+ } catch {
342
+ // malformed JSON — skip
343
+ }
344
+ }
345
+ }
346
+
347
+ private handleMessage(msg: any): void {
348
+ // Response to a request we sent
349
+ if (msg.id != null && this.pending.has(msg.id)) {
350
+ const p = this.pending.get(msg.id)!
351
+ this.pending.delete(msg.id)
352
+ if (msg.error) {
353
+ p.reject(new Error(`LSP error ${msg.error.code}: ${msg.error.message}`))
354
+ } else {
355
+ p.resolve(msg.result)
356
+ }
357
+ return
358
+ }
359
+ // Server notifications / requests — ignore for indexing purposes
360
+ }
361
+
362
+ private normalizeLocations(result: any): LSPLocation[] {
363
+ if (!result) return []
364
+ if (Array.isArray(result)) return result
365
+ // Single Location
366
+ if (result.uri) return [result]
367
+ return []
368
+ }
369
+ }