@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.
- package/file-indexer.ts +13 -0
- package/index.ts +7 -1
- package/package.json +12 -3
- package/tools/codeindex.ts +155 -6
- package/tools/read-interceptor.ts +127 -0
- package/tools/search.ts +14 -1
- package/vectorizer/analyzers/lsp-analyzer.ts +293 -0
- package/vectorizer/analyzers/lsp-client.ts +369 -0
- package/vectorizer/analyzers/regex-analyzer.ts +255 -0
- package/vectorizer/graph-builder.ts +198 -0
- package/vectorizer/graph-db.ts +289 -0
- package/vectorizer/index.js +167 -9
- package/vectorizer/usage-tracker.ts +204 -0
- package/vectorizer.yaml +14 -0
|
@@ -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
|
+
}
|