@danypops/lector 0.1.7 → 0.1.9
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/README.md +11 -7
- package/package.json +5 -1
- package/src/adapters/fallback-code-intelligence-index.ts +73 -0
- package/src/adapters/find-source-files.ts +1 -1
- package/src/adapters/git-repo-fetcher.ts +154 -37
- package/src/adapters/lsp/discover-seed-file.ts +17 -9
- package/src/adapters/lsp/json-rpc-stream.ts +52 -2
- package/src/adapters/lsp/language-server-process.ts +54 -10
- package/src/adapters/lsp/lsp-symbol-index.ts +167 -41
- package/src/adapters/normalize-npm-repository.ts +77 -0
- package/src/adapters/npm-lockfile-version-resolver.ts +519 -0
- package/src/adapters/npm-package-source-resolver.ts +322 -0
- package/src/adapters/npm-registry-client.ts +304 -0
- package/src/adapters/polyglot-code-intelligence-index.ts +135 -0
- package/src/adapters/sqlite-symbol-graph.ts +68 -3
- package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +61 -11
- package/src/adapters/typescript-compiler-symbol-index.ts +129 -0
- package/src/cli.ts +97 -23
- package/src/daemon.ts +4 -0
- package/src/domain/find-workspace-symbols.ts +4 -3
- package/src/domain/installed-package-version.ts +66 -0
- package/src/domain/intelligence-provenance.ts +27 -0
- package/src/domain/language-server-descriptor.ts +22 -0
- package/src/domain/npm-package-metadata.ts +26 -0
- package/src/domain/package-source.ts +143 -0
- package/src/domain/repo-fetch-result.ts +33 -1
- package/src/domain/resolve-package-source.ts +204 -0
- package/src/domain/symbol-graph-generation.ts +5 -0
- package/src/domain/symbol-query.ts +16 -0
- package/src/domain/workspace-symbol.ts +13 -0
- package/src/index.ts +68 -4
- package/src/ports/installed-package-version-resolver-port.ts +5 -0
- package/src/ports/npm-registry-port.ts +5 -0
- package/src/ports/package-source-resolver-port.ts +5 -0
- package/src/ports/repo-fetcher-port.ts +2 -2
- package/src/ports/symbol-index-port.ts +4 -2
- package/src/service.ts +152 -43
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
-
import { encodeJsonRpcMessage, type JsonRpcMessage, JsonRpcStreamDecoder } from "./json-rpc-stream.ts";
|
|
2
|
+
import { encodeJsonRpcMessage, type JsonRpcMessage, JsonRpcMessageLimitExceeded, JsonRpcStreamDecoder } from "./json-rpc-stream.ts";
|
|
3
3
|
|
|
4
4
|
export interface LanguageServerProcessOptions {
|
|
5
5
|
command: string;
|
|
@@ -9,6 +9,10 @@ export interface LanguageServerProcessOptions {
|
|
|
9
9
|
env?: Readonly<Record<string, string | undefined>>;
|
|
10
10
|
/** Per-request timeout. Default 10s. */
|
|
11
11
|
requestTimeoutMs?: number;
|
|
12
|
+
/** Maximum simultaneous requests. Default 64. */
|
|
13
|
+
maxPendingRequests?: number;
|
|
14
|
+
/** Maximum decoded server message size. Default 8 MiB. */
|
|
15
|
+
maxMessageBytes?: number;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/** Raised when a request is sent (or was pending) after the server process already exited. */
|
|
@@ -20,6 +24,13 @@ export class LanguageServerProcessExited extends Error {
|
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/** Raised when a request does not receive a response within its timeout. */
|
|
27
|
+
export class LanguageServerCapacityExceeded extends Error {
|
|
28
|
+
constructor(readonly maxPendingRequests: number) {
|
|
29
|
+
super(`language server request capacity reached (${maxPendingRequests} pending)`);
|
|
30
|
+
this.name = "LanguageServerCapacityExceeded";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
export class LanguageServerRequestTimedOut extends Error {
|
|
24
35
|
constructor(
|
|
25
36
|
readonly method: string,
|
|
@@ -51,23 +62,34 @@ type NotificationHandler = (params: unknown) => void;
|
|
|
51
62
|
*/
|
|
52
63
|
export class LanguageServerProcess {
|
|
53
64
|
private readonly child: ChildProcessWithoutNullStreams;
|
|
54
|
-
private readonly decoder
|
|
65
|
+
private readonly decoder: JsonRpcStreamDecoder;
|
|
55
66
|
private readonly pending = new Map<number, PendingRequest>();
|
|
56
67
|
private readonly notificationHandlers = new Map<string, Set<NotificationHandler>>();
|
|
57
68
|
private readonly requestTimeoutMs: number;
|
|
69
|
+
private readonly maxPendingRequests: number;
|
|
70
|
+
private readonly maxMessageBytes: number;
|
|
58
71
|
private nextId = 1;
|
|
59
72
|
private processExited = false;
|
|
60
73
|
private exitError: Error;
|
|
61
74
|
|
|
62
|
-
private constructor(child: ChildProcessWithoutNullStreams, label: string, requestTimeoutMs: number) {
|
|
75
|
+
private constructor(child: ChildProcessWithoutNullStreams, label: string, requestTimeoutMs: number, maxPendingRequests: number, maxMessageBytes: number) {
|
|
63
76
|
this.child = child;
|
|
64
77
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
78
|
+
this.maxPendingRequests = maxPendingRequests;
|
|
79
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
80
|
+
this.decoder = new JsonRpcStreamDecoder({ maxMessageBytes });
|
|
65
81
|
this.exitError = new LanguageServerProcessExited(label);
|
|
82
|
+
this.child.stderr.resume();
|
|
66
83
|
|
|
67
84
|
this.child.stdout.on("data", (chunk: Buffer) => {
|
|
68
|
-
|
|
85
|
+
try {
|
|
86
|
+
for (const message of this.decoder.push(chunk)) this.dispatch(message);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
89
|
+
}
|
|
69
90
|
});
|
|
70
91
|
|
|
92
|
+
this.child.once("error", (error) => this.fail(error));
|
|
71
93
|
this.child.once("exit", () => {
|
|
72
94
|
this.processExited = true;
|
|
73
95
|
for (const [id, request] of this.pending) {
|
|
@@ -84,14 +106,18 @@ export class LanguageServerProcess {
|
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
static spawnProcess(options: LanguageServerProcessOptions): LanguageServerProcess {
|
|
109
|
+
const maxPendingRequests = options.maxPendingRequests ?? 64;
|
|
110
|
+
const maxMessageBytes = options.maxMessageBytes ?? 8 * 1024 * 1024;
|
|
111
|
+
if (!Number.isSafeInteger(maxPendingRequests) || maxPendingRequests < 1) throw new TypeError("maxPendingRequests must be a positive safe integer");
|
|
112
|
+
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes < 1) throw new TypeError("maxMessageBytes must be a positive safe integer");
|
|
87
113
|
const child = spawn(options.command, [...(options.args ?? [])], {
|
|
88
114
|
cwd: options.cwd,
|
|
89
115
|
env: { ...process.env, ...options.env },
|
|
90
116
|
stdio: ["pipe", "pipe", "pipe"],
|
|
91
117
|
// Own process group on POSIX so stop() can kill every descendant as a unit.
|
|
92
118
|
detached: process.platform !== "win32",
|
|
93
|
-
})
|
|
94
|
-
return new LanguageServerProcess(child, options.command, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
119
|
+
});
|
|
120
|
+
return new LanguageServerProcess(child, options.command, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, maxPendingRequests, maxMessageBytes);
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
private dispatch(message: JsonRpcMessage): void {
|
|
@@ -120,20 +146,25 @@ export class LanguageServerProcess {
|
|
|
120
146
|
|
|
121
147
|
async request<T>(method: string, params: unknown): Promise<T> {
|
|
122
148
|
if (this.processExited) throw this.exitError;
|
|
149
|
+
if (this.pending.size >= this.maxPendingRequests) throw new LanguageServerCapacityExceeded(this.maxPendingRequests);
|
|
123
150
|
const id = this.nextId++;
|
|
151
|
+
const message = encodeJsonRpcMessage({ jsonrpc: "2.0", id, method, params });
|
|
152
|
+
if (message.byteLength > this.maxMessageBytes) throw new JsonRpcMessageLimitExceeded("message-bytes", this.maxMessageBytes, message.byteLength);
|
|
124
153
|
return new Promise<T>((resolve, reject) => {
|
|
125
154
|
const timer = setTimeout(() => {
|
|
126
155
|
this.pending.delete(id);
|
|
127
156
|
reject(new LanguageServerRequestTimedOut(method, this.requestTimeoutMs));
|
|
128
157
|
}, this.requestTimeoutMs);
|
|
129
158
|
this.pending.set(id, { resolve: resolve as (result: unknown) => void, reject, timer });
|
|
130
|
-
this.child.stdin.write(
|
|
159
|
+
this.child.stdin.write(message);
|
|
131
160
|
});
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
notify(method: string, params: unknown): void {
|
|
135
164
|
if (this.processExited) return;
|
|
136
|
-
|
|
165
|
+
const message = encodeJsonRpcMessage({ jsonrpc: "2.0", method, params });
|
|
166
|
+
if (message.byteLength > this.maxMessageBytes) throw new JsonRpcMessageLimitExceeded("message-bytes", this.maxMessageBytes, message.byteLength);
|
|
167
|
+
this.child.stdin.write(message);
|
|
137
168
|
}
|
|
138
169
|
|
|
139
170
|
/**
|
|
@@ -142,6 +173,18 @@ export class LanguageServerProcess {
|
|
|
142
173
|
* graceful path hung, errored, or the process simply ignored it -- kills
|
|
143
174
|
* the entire process group so no descendant is left running.
|
|
144
175
|
*/
|
|
176
|
+
private fail(error: Error): void {
|
|
177
|
+
if (this.processExited) return;
|
|
178
|
+
this.processExited = true;
|
|
179
|
+
this.exitError = error;
|
|
180
|
+
for (const [id, request] of this.pending) {
|
|
181
|
+
clearTimeout(request.timer);
|
|
182
|
+
request.reject(error);
|
|
183
|
+
this.pending.delete(id);
|
|
184
|
+
}
|
|
185
|
+
this.killProcessGroup();
|
|
186
|
+
}
|
|
187
|
+
|
|
145
188
|
async stop(stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<void> {
|
|
146
189
|
if (this.processExited) return;
|
|
147
190
|
|
|
@@ -172,8 +215,9 @@ export class LanguageServerProcess {
|
|
|
172
215
|
|
|
173
216
|
private killProcessGroup(): void {
|
|
174
217
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
218
|
+
const pid = this.child.pid;
|
|
219
|
+
if (process.platform !== "win32" && pid !== undefined) {
|
|
220
|
+
process.kill(-pid, "SIGKILL"); // negative pid == the whole process group
|
|
177
221
|
} else {
|
|
178
222
|
this.child.kill("SIGKILL");
|
|
179
223
|
}
|
|
@@ -1,18 +1,59 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../../domain/call-hierarchy.ts";
|
|
5
5
|
import type { CodeRange } from "../../domain/code-range.ts";
|
|
6
6
|
import type { Diagnostic, DiagnosticSeverity } from "../../domain/diagnostic.ts";
|
|
7
7
|
import type { DocumentSymbolEntry } from "../../domain/document-symbol.ts";
|
|
8
8
|
import type { Hover } from "../../domain/hover.ts";
|
|
9
|
+
import type { IntelligenceProvenance, SymbolSearchBounds } from "../../domain/intelligence-provenance.ts";
|
|
9
10
|
import { DEFAULT_SETTLE_MS, type LanguageServerDescriptor } from "../../domain/language-server-descriptor.ts";
|
|
10
|
-
import type { WorkspaceLocation, WorkspaceSymbol } from "../../domain/workspace-symbol.ts";
|
|
11
|
+
import type { SymbolSearchResult, WorkspaceLocation, WorkspaceSymbol } from "../../domain/workspace-symbol.ts";
|
|
11
12
|
import type { CodeIntelligencePort } from "../../ports/code-intelligence-port.ts";
|
|
12
13
|
import type { SymbolIndexPort } from "../../ports/symbol-index-port.ts";
|
|
14
|
+
import { TypeScriptCompilerSymbolIndex } from "../typescript-compiler-symbol-index.ts";
|
|
13
15
|
import { resolveSeedFile } from "./discover-seed-file.ts";
|
|
14
16
|
import { LanguageServerProcess } from "./language-server-process.ts";
|
|
15
17
|
|
|
18
|
+
const DEFAULT_MAX_SYMBOL_RESULTS = 1_000;
|
|
19
|
+
const DEFAULT_MAX_OPEN_FILES = 256;
|
|
20
|
+
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
const MAX_SETTLE_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
export interface LspSymbolIndexOptions {
|
|
24
|
+
readonly maxOpenFiles?: number;
|
|
25
|
+
readonly maxFileBytes?: number;
|
|
26
|
+
readonly maxRefreshBytes?: number;
|
|
27
|
+
readonly maxFallbackSeedFiles?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LanguageFileOutsideWorkspace extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly path: string,
|
|
33
|
+
readonly root: string,
|
|
34
|
+
) {
|
|
35
|
+
super(`language file "${path}" resolves outside workspace root "${root}"`);
|
|
36
|
+
this.name = "LanguageFileOutsideWorkspace";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class LanguageFileLimitExceeded extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly limit: "open-files" | "file-bytes" | "refresh-bytes",
|
|
43
|
+
readonly max: number,
|
|
44
|
+
readonly observed: number,
|
|
45
|
+
) {
|
|
46
|
+
super(`language intelligence ${limit} limit exceeded: ${observed} > ${max}`);
|
|
47
|
+
this.name = "LanguageFileLimitExceeded";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveLimit(value: number | undefined, fallback: number, field: string): number {
|
|
52
|
+
const result = value ?? fallback;
|
|
53
|
+
if (!Number.isSafeInteger(result) || result < 1) throw new TypeError(`${field} must be a positive safe integer`);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
16
57
|
const LSP_SYMBOL_KIND_NAMES: Readonly<Record<number, string>> = {
|
|
17
58
|
1: "file",
|
|
18
59
|
2: "module",
|
|
@@ -237,19 +278,42 @@ function normalizeDocumentSymbol(path: string, item: LspDocumentSymbol | LspSymb
|
|
|
237
278
|
* actually loaded -- open a file first to guarantee its usages are included.
|
|
238
279
|
*/
|
|
239
280
|
export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
281
|
+
readonly provenance: IntelligenceProvenance;
|
|
240
282
|
private readonly cwd: string;
|
|
283
|
+
private readonly canonicalCwd: string;
|
|
241
284
|
private readonly descriptor: LanguageServerDescriptor;
|
|
242
285
|
private readonly explicitSeedFile: string | undefined;
|
|
243
|
-
private
|
|
286
|
+
private fallbackSeedFile: string | undefined;
|
|
287
|
+
private readonly maxOpenFiles: number;
|
|
288
|
+
private readonly maxFileBytes: number;
|
|
289
|
+
private readonly maxRefreshBytes: number;
|
|
290
|
+
private readonly maxFallbackSeedFiles: number;
|
|
291
|
+
private readonly openedFiles = new Map<string, { version: number; content: string }>();
|
|
244
292
|
private readonly latestDiagnostics = new Map<string, Diagnostic[]>();
|
|
245
293
|
private readonly diagnosticsWaiters = new Map<string, Array<() => void>>();
|
|
246
294
|
private process: LanguageServerProcess | undefined;
|
|
247
295
|
private initializing: Promise<LanguageServerProcess> | undefined;
|
|
248
296
|
|
|
249
|
-
constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string) {
|
|
250
|
-
this.cwd = cwd;
|
|
297
|
+
constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string, options: LspSymbolIndexOptions = {}) {
|
|
298
|
+
this.cwd = resolve(cwd);
|
|
299
|
+
this.canonicalCwd = realpathSync(this.cwd);
|
|
251
300
|
this.descriptor = descriptor;
|
|
252
301
|
this.explicitSeedFile = seedFile;
|
|
302
|
+
this.maxOpenFiles = positiveLimit(options.maxOpenFiles, DEFAULT_MAX_OPEN_FILES, "maxOpenFiles");
|
|
303
|
+
this.maxFileBytes = positiveLimit(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, "maxFileBytes");
|
|
304
|
+
this.maxRefreshBytes = positiveLimit(options.maxRefreshBytes, 50 * 1024 * 1024, "maxRefreshBytes");
|
|
305
|
+
this.maxFallbackSeedFiles = positiveLimit(options.maxFallbackSeedFiles, 8, "maxFallbackSeedFiles");
|
|
306
|
+
const settleMs = descriptor.settleMs ?? DEFAULT_SETTLE_MS;
|
|
307
|
+
if (!Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > MAX_SETTLE_MS)
|
|
308
|
+
throw new TypeError(`settleMs must be an integer from 0 through ${MAX_SETTLE_MS}`);
|
|
309
|
+
this.provenance = {
|
|
310
|
+
fidelity: "semantic",
|
|
311
|
+
backend: descriptor.backendId,
|
|
312
|
+
languageId: descriptor.languageId,
|
|
313
|
+
authority: "language-server",
|
|
314
|
+
freshness: "live-process",
|
|
315
|
+
limitations: [],
|
|
316
|
+
};
|
|
253
317
|
}
|
|
254
318
|
|
|
255
319
|
/** Undefined before the server process has been spawned (first real query). */
|
|
@@ -257,14 +321,27 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
257
321
|
return this.process?.pid;
|
|
258
322
|
}
|
|
259
323
|
|
|
260
|
-
private
|
|
324
|
+
private resolveTargetPath(path: string): string {
|
|
325
|
+
const absolute = resolve(this.cwd, path);
|
|
326
|
+
const canonical = realpathSync(absolute);
|
|
327
|
+
const relativeToRoot = relative(this.canonicalCwd, canonical);
|
|
328
|
+
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) {
|
|
329
|
+
throw new LanguageFileOutsideWorkspace(path, this.cwd);
|
|
330
|
+
}
|
|
331
|
+
return absolute;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async ensureInitialized(initialPath?: string): Promise<LanguageServerProcess> {
|
|
335
|
+
const initialTargetPath = initialPath ? this.resolveTargetPath(initialPath) : undefined;
|
|
261
336
|
if (this.process) return this.process;
|
|
262
337
|
if (!this.initializing) {
|
|
338
|
+
let spawned: LanguageServerProcess | undefined;
|
|
263
339
|
this.initializing = (async () => {
|
|
264
340
|
const proc = LanguageServerProcess.spawnProcess({
|
|
265
341
|
...resolveLanguageServerCommand(this.descriptor),
|
|
266
342
|
cwd: this.cwd,
|
|
267
343
|
});
|
|
344
|
+
spawned = proc;
|
|
268
345
|
proc.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
269
346
|
const { uri, diagnostics } = params as LspPublishDiagnosticsParams;
|
|
270
347
|
const path = fileURLToPath(uri);
|
|
@@ -296,24 +373,16 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
296
373
|
});
|
|
297
374
|
proc.notify("initialized", {});
|
|
298
375
|
|
|
299
|
-
const
|
|
300
|
-
const seedPath =
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
languageId: this.descriptor.languageId,
|
|
305
|
-
version: 1,
|
|
306
|
-
text: readFileSync(seedPath, "utf-8"),
|
|
307
|
-
},
|
|
308
|
-
});
|
|
309
|
-
this.openedFiles.add(seedPath);
|
|
310
|
-
// No server signals "project loaded"; must match ensureFileOpen's wait below,
|
|
311
|
-
// since the seed file is often the first file a caller queries.
|
|
312
|
-
await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
|
|
376
|
+
const configuredSeedFile = this.fallbackSeedFile ?? this.explicitSeedFile;
|
|
377
|
+
const seedPath = configuredSeedFile
|
|
378
|
+
? resolve(this.cwd, configuredSeedFile)
|
|
379
|
+
: (initialTargetPath ?? resolve(this.cwd, resolveSeedFile(this.cwd, this.descriptor)));
|
|
380
|
+
await this.ensureFileOpen(proc, seedPath);
|
|
313
381
|
|
|
314
382
|
this.process = proc;
|
|
315
383
|
return proc;
|
|
316
|
-
})().catch((error: unknown) => {
|
|
384
|
+
})().catch(async (error: unknown) => {
|
|
385
|
+
await spawned?.stop();
|
|
317
386
|
// A failed initialize must not permanently poison this workspace's index --
|
|
318
387
|
// the next call retries fresh rather than replaying the same rejection forever.
|
|
319
388
|
this.initializing = undefined;
|
|
@@ -323,13 +392,38 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
323
392
|
return this.initializing;
|
|
324
393
|
}
|
|
325
394
|
|
|
326
|
-
|
|
395
|
+
private readBoundedFile(path: string): string {
|
|
396
|
+
const content = readFileSync(path);
|
|
397
|
+
if (content.byteLength > this.maxFileBytes) throw new LanguageFileLimitExceeded("file-bytes", this.maxFileBytes, content.byteLength);
|
|
398
|
+
return content.toString("utf-8");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Opens a file or sends a monotonic full-document change when its disk content changed since the prior query. */
|
|
327
402
|
private async ensureFileOpen(proc: LanguageServerProcess, path: string): Promise<void> {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
403
|
+
path = this.resolveTargetPath(path);
|
|
404
|
+
const content = this.readBoundedFile(path);
|
|
405
|
+
const opened = this.openedFiles.get(path);
|
|
406
|
+
if (opened?.content === content) return;
|
|
407
|
+
if (!opened) {
|
|
408
|
+
if (this.openedFiles.size >= this.maxOpenFiles) throw new LanguageFileLimitExceeded("open-files", this.maxOpenFiles, this.openedFiles.size + 1);
|
|
409
|
+
proc.notify("textDocument/didOpen", {
|
|
410
|
+
textDocument: {
|
|
411
|
+
uri: pathToFileURL(path).href,
|
|
412
|
+
languageId: this.descriptor.documentLanguageIds?.[extname(path)] ?? this.descriptor.languageId,
|
|
413
|
+
version: 1,
|
|
414
|
+
text: content,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
this.openedFiles.set(path, { version: 1, content });
|
|
418
|
+
} else {
|
|
419
|
+
const version = opened.version + 1;
|
|
420
|
+
this.latestDiagnostics.delete(path);
|
|
421
|
+
proc.notify("textDocument/didChange", {
|
|
422
|
+
textDocument: { uri: pathToFileURL(path).href, version },
|
|
423
|
+
contentChanges: [{ text: content }],
|
|
424
|
+
});
|
|
425
|
+
this.openedFiles.set(path, { version, content });
|
|
426
|
+
}
|
|
333
427
|
await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
|
|
334
428
|
}
|
|
335
429
|
|
|
@@ -346,10 +440,39 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
346
440
|
});
|
|
347
441
|
}
|
|
348
442
|
|
|
349
|
-
async
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
443
|
+
private async restartForSeed(seedFile: string): Promise<LanguageServerProcess> {
|
|
444
|
+
await this.process?.stop();
|
|
445
|
+
this.process = undefined;
|
|
446
|
+
this.initializing = undefined;
|
|
447
|
+
this.openedFiles.clear();
|
|
448
|
+
this.latestDiagnostics.clear();
|
|
449
|
+
this.diagnosticsWaiters.clear();
|
|
450
|
+
this.fallbackSeedFile = seedFile;
|
|
451
|
+
return this.ensureInitialized();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async findSymbols(query: string, bounds: SymbolSearchBounds = { maxResults: DEFAULT_MAX_SYMBOL_RESULTS }): Promise<SymbolSearchResult> {
|
|
455
|
+
if (!Number.isSafeInteger(bounds.maxResults) || bounds.maxResults < 1) throw new TypeError("maxResults must be a positive safe integer");
|
|
456
|
+
let proc = await this.ensureInitialized();
|
|
457
|
+
let refreshedBytes = 0;
|
|
458
|
+
for (const path of this.openedFiles.keys()) {
|
|
459
|
+
await this.ensureFileOpen(proc, path);
|
|
460
|
+
refreshedBytes += Buffer.byteLength(this.openedFiles.get(path)?.content ?? "", "utf-8");
|
|
461
|
+
if (refreshedBytes > this.maxRefreshBytes) throw new LanguageFileLimitExceeded("refresh-bytes", this.maxRefreshBytes, refreshedBytes);
|
|
462
|
+
}
|
|
463
|
+
let results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
|
|
464
|
+
if (results.length === 0 && this.descriptor.languageId === "typescript") {
|
|
465
|
+
const candidates = await new TypeScriptCompilerSymbolIndex(this.cwd, { maxResults: this.maxFallbackSeedFiles }).findSymbols(query, {
|
|
466
|
+
maxResults: this.maxFallbackSeedFiles,
|
|
467
|
+
});
|
|
468
|
+
const candidate = candidates.symbols[0];
|
|
469
|
+
if (candidate) {
|
|
470
|
+
proc = await this.restartForSeed(candidate.location.path);
|
|
471
|
+
results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const truncated = results.length > bounds.maxResults;
|
|
475
|
+
const symbols: WorkspaceSymbol[] = results.slice(0, bounds.maxResults).map((symbol) => ({
|
|
353
476
|
name: symbol.name,
|
|
354
477
|
kind: LSP_SYMBOL_KIND_NAMES[symbol.kind] ?? "unknown",
|
|
355
478
|
location: {
|
|
@@ -359,10 +482,11 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
359
482
|
},
|
|
360
483
|
containerName: symbol.containerName,
|
|
361
484
|
}));
|
|
485
|
+
return { symbols, truncated, provenance: this.provenance };
|
|
362
486
|
}
|
|
363
487
|
|
|
364
488
|
async goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
365
|
-
const proc = await this.ensureInitialized();
|
|
489
|
+
const proc = await this.ensureInitialized(at.path);
|
|
366
490
|
await this.ensureFileOpen(proc, at.path);
|
|
367
491
|
const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/definition", {
|
|
368
492
|
textDocument: { uri: pathToFileURL(at.path).href },
|
|
@@ -372,7 +496,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
372
496
|
}
|
|
373
497
|
|
|
374
498
|
async goToImplementation(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
375
|
-
const proc = await this.ensureInitialized();
|
|
499
|
+
const proc = await this.ensureInitialized(at.path);
|
|
376
500
|
await this.ensureFileOpen(proc, at.path);
|
|
377
501
|
const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/implementation", {
|
|
378
502
|
textDocument: { uri: pathToFileURL(at.path).href },
|
|
@@ -382,7 +506,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
382
506
|
}
|
|
383
507
|
|
|
384
508
|
async findReferences(at: WorkspaceLocation, includeDeclaration: boolean): Promise<WorkspaceLocation[]> {
|
|
385
|
-
const proc = await this.ensureInitialized();
|
|
509
|
+
const proc = await this.ensureInitialized(at.path);
|
|
386
510
|
await this.ensureFileOpen(proc, at.path);
|
|
387
511
|
const results =
|
|
388
512
|
(await proc.request<LspLocation[] | null>("textDocument/references", {
|
|
@@ -394,7 +518,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
394
518
|
}
|
|
395
519
|
|
|
396
520
|
async hover(at: WorkspaceLocation): Promise<Hover | undefined> {
|
|
397
|
-
const proc = await this.ensureInitialized();
|
|
521
|
+
const proc = await this.ensureInitialized(at.path);
|
|
398
522
|
await this.ensureFileOpen(proc, at.path);
|
|
399
523
|
const result = await proc.request<LspHover | null>("textDocument/hover", {
|
|
400
524
|
textDocument: { uri: pathToFileURL(at.path).href },
|
|
@@ -405,7 +529,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
405
529
|
}
|
|
406
530
|
|
|
407
531
|
async documentSymbols(path: string): Promise<DocumentSymbolEntry[]> {
|
|
408
|
-
const proc = await this.ensureInitialized();
|
|
532
|
+
const proc = await this.ensureInitialized(path);
|
|
409
533
|
await this.ensureFileOpen(proc, path);
|
|
410
534
|
const results =
|
|
411
535
|
(await proc.request<(LspDocumentSymbol | LspSymbolInformation)[] | null>("textDocument/documentSymbol", {
|
|
@@ -415,7 +539,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
415
539
|
}
|
|
416
540
|
|
|
417
541
|
async diagnostics(path: string): Promise<Diagnostic[]> {
|
|
418
|
-
const proc = await this.ensureInitialized();
|
|
542
|
+
const proc = await this.ensureInitialized(path);
|
|
419
543
|
await this.ensureFileOpen(proc, path);
|
|
420
544
|
if (!this.latestDiagnostics.has(path)) await this.waitForDiagnosticsNotification(path, 5000);
|
|
421
545
|
return this.latestDiagnostics.get(path) ?? [];
|
|
@@ -432,13 +556,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
432
556
|
}
|
|
433
557
|
|
|
434
558
|
async prepareCallHierarchy(at: WorkspaceLocation): Promise<CallHierarchyEntry[]> {
|
|
435
|
-
const proc = await this.ensureInitialized();
|
|
559
|
+
const proc = await this.ensureInitialized(at.path);
|
|
436
560
|
const items = await this.prepareCallHierarchyRaw(proc, at);
|
|
437
561
|
return items.map((item) => normalizeCallHierarchyItem(item));
|
|
438
562
|
}
|
|
439
563
|
|
|
440
564
|
async incomingCalls(at: WorkspaceLocation): Promise<IncomingCall[]> {
|
|
441
|
-
const proc = await this.ensureInitialized();
|
|
565
|
+
const proc = await this.ensureInitialized(at.path);
|
|
442
566
|
const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
|
|
443
567
|
if (!root) return [];
|
|
444
568
|
const results = (await proc.request<LspCallHierarchyIncomingCall[] | null>("callHierarchy/incomingCalls", { item: root })) ?? [];
|
|
@@ -449,7 +573,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
449
573
|
}
|
|
450
574
|
|
|
451
575
|
async outgoingCalls(at: WorkspaceLocation): Promise<OutgoingCall[]> {
|
|
452
|
-
const proc = await this.ensureInitialized();
|
|
576
|
+
const proc = await this.ensureInitialized(at.path);
|
|
453
577
|
const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
|
|
454
578
|
if (!root) return [];
|
|
455
579
|
const results = (await proc.request<LspCallHierarchyOutgoingCall[] | null>("callHierarchy/outgoingCalls", { item: root })) ?? [];
|
|
@@ -462,7 +586,9 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
462
586
|
}
|
|
463
587
|
|
|
464
588
|
async close(): Promise<void> {
|
|
465
|
-
|
|
589
|
+
const initializing = this.initializing;
|
|
590
|
+
const process = this.process ?? (initializing ? await initializing.catch(() => undefined) : undefined);
|
|
591
|
+
await process?.stop();
|
|
466
592
|
this.process = undefined;
|
|
467
593
|
this.initializing = undefined;
|
|
468
594
|
this.openedFiles.clear();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { NpmRepositoryMetadata } from "../domain/npm-package-metadata.ts";
|
|
3
|
+
import type { RepoReference } from "../domain/repo-reference.ts";
|
|
4
|
+
|
|
5
|
+
export interface NormalizedNpmRepository {
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly host: string;
|
|
8
|
+
readonly owner: string;
|
|
9
|
+
readonly repo: string;
|
|
10
|
+
readonly directory: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function containsControlCharacter(value: string): boolean {
|
|
14
|
+
for (let index = 0; index < value.length; index++) {
|
|
15
|
+
const code = value.charCodeAt(index);
|
|
16
|
+
if (code <= 31 || code === 127) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizedDirectory(raw: string | null): string | null {
|
|
22
|
+
if (raw === null) return null;
|
|
23
|
+
if (raw.length === 0 || raw.length > 2048 || raw.includes("\\") || containsControlCharacter(raw) || posix.isAbsolute(raw)) return null;
|
|
24
|
+
const normalized = posix.normalize(raw.replace(/^\.\//, "")).replace(/\/$/, "");
|
|
25
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.length === 0) return null;
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shorthand(raw: string): string {
|
|
30
|
+
for (const [prefix, host] of [
|
|
31
|
+
["github:", "github.com"],
|
|
32
|
+
["gitlab:", "gitlab.com"],
|
|
33
|
+
["bitbucket:", "bitbucket.org"],
|
|
34
|
+
] as const) {
|
|
35
|
+
if (raw.startsWith(prefix)) return `https://${host}/${raw.slice(prefix.length)}`;
|
|
36
|
+
}
|
|
37
|
+
if (/^[^/:\s]+\/[^/\s]+(?:\.git)?$/.test(raw)) return `https://github.com/${raw}`;
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function repositoryUrl(raw: string): URL | null {
|
|
42
|
+
let candidate = shorthand(raw.trim()).replace(/^git\+/, "");
|
|
43
|
+
const scp = candidate.match(/^git@([^:]+):(.+)$/);
|
|
44
|
+
if (scp) candidate = `ssh://git@${scp[1]}/${scp[2]}`;
|
|
45
|
+
if (candidate.startsWith("git://")) candidate = `https://${candidate.slice("git://".length)}`;
|
|
46
|
+
let parsed: URL;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(candidate);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!["https:", "http:", "ssh:"].includes(parsed.protocol) || parsed.search || parsed.hash || parsed.password) return null;
|
|
53
|
+
if (parsed.username && parsed.username !== "git") return null;
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeNpmRepository(metadata: NpmRepositoryMetadata): NormalizedNpmRepository | null {
|
|
58
|
+
if (metadata.type !== null && metadata.type.toLowerCase() !== "git") return null;
|
|
59
|
+
const parsed = repositoryUrl(metadata.url);
|
|
60
|
+
if (parsed === null) return null;
|
|
61
|
+
const segments = parsed.pathname
|
|
62
|
+
.replace(/^\//, "")
|
|
63
|
+
.replace(/\.git$/, "")
|
|
64
|
+
.split("/")
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
if (segments.length !== 2) return null;
|
|
67
|
+
const [owner, repo] = segments;
|
|
68
|
+
if (!owner || !repo || owner === "." || owner === ".." || repo === "." || repo === "..") return null;
|
|
69
|
+
const directory = normalizedDirectory(metadata.directory);
|
|
70
|
+
if (metadata.directory !== null && directory === null) return null;
|
|
71
|
+
const host = parsed.hostname.toLowerCase();
|
|
72
|
+
return { url: `https://${host}/${owner}/${repo}.git`, host, owner, repo, directory };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function npmRepositoryReference(repository: NormalizedNpmRepository, ref: string): RepoReference {
|
|
76
|
+
return { host: repository.host, owner: repository.owner, repo: repository.repo, ref };
|
|
77
|
+
}
|