@comfanion/usethis_search 3.0.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.
@@ -1,4 +1,19 @@
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"
1
15
  import { ChunkWithId } from "../graph-builder"
16
+ import { LSPClient, LSPSymbolInformation, SymbolKind } from "./lsp-client"
2
17
 
3
18
  export interface Relation {
4
19
  from: string
@@ -9,134 +24,248 @@ export interface Relation {
9
24
  line?: number
10
25
  }
11
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
+
12
59
  export class LSPAnalyzer {
13
- private readonly timeout = 5000
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
+ }
14
71
 
72
+ // ---- public API ---------------------------------------------------------
73
+
74
+ /** Check if an LSP server is available for this file's language. */
15
75
  async isAvailable(filePath: string): Promise<boolean> {
16
76
  try {
17
- const ext = filePath.split(".").pop()
18
- if (!ext) return false
19
-
20
- const language = this.getLanguage(ext)
77
+ const language = this.getLanguage(filePath)
21
78
  if (!language) return false
22
-
23
- return this.checkLSPServer(language)
79
+ return LSPClient.isAvailable(language)
24
80
  } catch {
25
81
  return false
26
82
  }
27
83
  }
28
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
+ */
29
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!
30
118
  const relations: Relation[] = []
31
119
 
32
120
  try {
33
- const ext = filePath.split(".").pop()
34
- if (!ext) return []
121
+ // Open the document
122
+ await client.openDocument(filePath, content, language)
123
+ this.filesOpened++
35
124
 
36
- const language = this.getLanguage(ext)
37
- if (!language) return []
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
+ }
38
131
 
39
- const lines = await this.readFileLines(filePath)
132
+ // Get all symbols in the file
133
+ const rawSymbols = await client.documentSymbol(filePath)
134
+ const symbols = this.flattenSymbols(rawSymbols)
40
135
 
41
- const symbols = await this.getDocumentSymbols(filePath, language)
42
- if (!symbols) return []
136
+ // Analyze each symbol
137
+ for (const sym of symbols) {
138
+ if (!ANALYZABLE_SYMBOL_KINDS.has(sym.kind)) continue
43
139
 
44
- for (const symbol of symbols) {
45
- const fromChunkId = this.findChunkForPosition(chunks, symbol.line)
140
+ const fromChunkId = this.findChunkForPosition(chunks, sym.selectionRange.start.line)
46
141
  if (!fromChunkId) continue
47
142
 
48
- if (symbol.type === "class" || symbol.type === "interface") {
49
- const implementations = await this.getImplementations(filePath, symbol.line, symbol.character, language)
50
- for (const impl of implementations) {
51
- const toChunkId = this.resolveTargetChunk(filePath, impl)
52
- if (toChunkId) {
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) {
53
172
  relations.push({
54
- from: fromChunkId,
55
- to: toChunkId,
56
- predicate: "implements",
173
+ from: toChunkId,
174
+ to: fromChunkId,
175
+ predicate: "used_by",
57
176
  weight: 1.0,
58
- source: "lsp"
177
+ source: "lsp",
178
+ line: ref.range.start.line,
59
179
  })
60
180
  }
61
181
  }
62
- }
182
+ } catch { /* server may not support references */ }
63
183
 
64
- const references = await this.getReferences(filePath, symbol.line, symbol.character, language)
65
- for (const ref of references) {
66
- const toChunkId = this.resolveTargetChunk(filePath, ref)
67
- if (toChunkId && toChunkId !== fromChunkId) {
68
- relations.push({
69
- from: toChunkId,
70
- to: fromChunkId,
71
- predicate: "used_by",
72
- weight: 1.0,
73
- source: "lsp"
74
- })
75
- }
76
- }
77
-
78
- const definitions = await this.getDefinitions(filePath, symbol.line, symbol.character, language)
79
- for (const def of definitions) {
80
- const toChunkId = this.resolveTargetChunk(filePath, def)
81
- if (toChunkId && toChunkId !== fromChunkId) {
82
- relations.push({
83
- from: fromChunkId,
84
- to: toChunkId,
85
- predicate: "references",
86
- weight: 1.0,
87
- source: "lsp"
88
- })
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
+ }
89
199
  }
90
- }
200
+ } catch { /* server may not support definition */ }
91
201
  }
92
- } catch (error) {
93
- return []
202
+
203
+ await client.closeDocument(filePath)
204
+ } catch {
205
+ // Any error — return whatever we collected so far (or empty)
94
206
  }
95
207
 
96
- return relations
208
+ return this.deduplicateRelations(relations)
97
209
  }
98
210
 
99
- private getLanguage(ext: string): string | null {
100
- const map: Record<string, string> = {
101
- ts: "typescript",
102
- js: "javascript",
103
- tsx: "typescriptreact",
104
- jsx: "javascriptreact",
105
- py: "python",
106
- go: "go",
107
- rs: "rust",
108
- java: "java",
109
- cpp: "cpp",
110
- c: "c",
111
- cs: "csharp"
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
112
218
  }
113
- return map[ext] || null
114
219
  }
115
220
 
116
- private checkLSPServer(language: string): Promise<boolean> {
117
- return Promise.resolve(false)
118
- }
221
+ // ---- internals ----------------------------------------------------------
119
222
 
120
- private async readFileLines(filePath: string): Promise<string[]> {
121
- const fs = await import("fs/promises")
122
- const content = await fs.readFile(filePath, "utf-8")
123
- return content.split("\n")
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
124
227
  }
125
228
 
126
- private async getDocumentSymbols(filePath: string, language: string): Promise<Array<{ name: string; type: string; line: number; character: number }> | null> {
127
- return null
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
+ }
128
240
  }
129
241
 
130
- private async getImplementations(filePath: string, line: number, character: number, language: string): Promise<Array<{ file: string; line: number; character: number }>> {
131
- return []
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
132
253
  }
133
254
 
134
- private async getReferences(filePath: string, line: number, character: number, language: string): Promise<Array<{ file: string; line: number; character: number }>> {
135
- return []
136
- }
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
137
263
 
138
- private async getDefinitions(filePath: string, line: number, character: number, language: string): Promise<Array<{ file: string; line: number; character: number }>> {
139
- return []
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`
140
269
  }
141
270
 
142
271
  private findChunkForPosition(chunks: ChunkWithId[], line: number): string | null {
@@ -147,16 +276,18 @@ export class LSPAnalyzer {
147
276
  }
148
277
  }
149
278
  }
150
- return null
279
+ // Fallback: if no chunk matches by line, return first chunk
280
+ return chunks.length > 0 ? chunks[0].chunk_id : null
151
281
  }
152
282
 
153
- private resolveTargetChunk(currentFile: string, target: { file: string; line: number; character: number }): string | null {
154
- if (target.file !== currentFile) {
155
- const path = target.file.replace(/[^a-zA-Z0-9]/g, "_")
156
- return `chunk_${path}_0`
157
- }
158
-
159
- const normalized = currentFile.replace(/[^a-zA-Z0-9]/g, "_")
160
- return `chunk_${normalized}_0`
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
+ })
161
292
  }
162
293
  }