@danypops/lector 0.1.6 → 0.1.8
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 +9 -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/json-rpc-stream.ts +52 -2
- package/src/adapters/lsp/language-server-process.ts +54 -10
- package/src/adapters/lsp/lsp-symbol-index.ts +133 -32
- 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/sqlite-symbol-graph.ts +47 -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 +87 -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 +19 -0
- package/src/domain/language-server-descriptor.ts +18 -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 +3 -0
- package/src/domain/symbol-query.ts +16 -0
- package/src/domain/workspace-symbol.ts +8 -0
- package/src/index.ts +61 -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 +89 -25
|
@@ -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,49 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { extname, join } 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 LanguageFileLimitExceeded extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly limit: "open-files" | "file-bytes" | "refresh-bytes",
|
|
33
|
+
readonly max: number,
|
|
34
|
+
readonly observed: number,
|
|
35
|
+
) {
|
|
36
|
+
super(`language intelligence ${limit} limit exceeded: ${observed} > ${max}`);
|
|
37
|
+
this.name = "LanguageFileLimitExceeded";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function positiveLimit(value: number | undefined, fallback: number, field: string): number {
|
|
42
|
+
const result = value ?? fallback;
|
|
43
|
+
if (!Number.isSafeInteger(result) || result < 1) throw new TypeError(`${field} must be a positive safe integer`);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
16
47
|
const LSP_SYMBOL_KIND_NAMES: Readonly<Record<number, string>> = {
|
|
17
48
|
1: "file",
|
|
18
49
|
2: "module",
|
|
@@ -119,6 +150,8 @@ interface LspPublishDiagnosticsParams {
|
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
/** LSP DiagnosticSeverity is 1-4; the spec recommends treating an absent severity as an error. */
|
|
153
|
+
const RUNTIME_EXECUTABLE = realpathSync(process.execPath);
|
|
154
|
+
|
|
122
155
|
const LSP_DIAGNOSTIC_SEVERITY_NAMES: Readonly<Record<number, DiagnosticSeverity>> = {
|
|
123
156
|
1: "error",
|
|
124
157
|
2: "warning",
|
|
@@ -134,7 +167,7 @@ function isHierarchicalDocumentSymbol(item: LspDocumentSymbol | LspSymbolInforma
|
|
|
134
167
|
/** Resolves a descriptor's launch into a spawnable command + args. */
|
|
135
168
|
function resolveLanguageServerCommand(descriptor: LanguageServerDescriptor): { command: string; args: string[] } {
|
|
136
169
|
if (descriptor.launch.kind === "npm-module") {
|
|
137
|
-
return { command:
|
|
170
|
+
return { command: RUNTIME_EXECUTABLE, args: [fileURLToPath(import.meta.resolve(descriptor.launch.entryModule)), ...descriptor.args] };
|
|
138
171
|
}
|
|
139
172
|
return { command: descriptor.launch.command, args: [...descriptor.args] };
|
|
140
173
|
}
|
|
@@ -235,19 +268,40 @@ function normalizeDocumentSymbol(path: string, item: LspDocumentSymbol | LspSymb
|
|
|
235
268
|
* actually loaded -- open a file first to guarantee its usages are included.
|
|
236
269
|
*/
|
|
237
270
|
export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
271
|
+
readonly provenance: IntelligenceProvenance;
|
|
238
272
|
private readonly cwd: string;
|
|
239
273
|
private readonly descriptor: LanguageServerDescriptor;
|
|
240
274
|
private readonly explicitSeedFile: string | undefined;
|
|
241
|
-
private
|
|
275
|
+
private fallbackSeedFile: string | undefined;
|
|
276
|
+
private readonly maxOpenFiles: number;
|
|
277
|
+
private readonly maxFileBytes: number;
|
|
278
|
+
private readonly maxRefreshBytes: number;
|
|
279
|
+
private readonly maxFallbackSeedFiles: number;
|
|
280
|
+
private readonly openedFiles = new Map<string, { version: number; content: string }>();
|
|
242
281
|
private readonly latestDiagnostics = new Map<string, Diagnostic[]>();
|
|
243
282
|
private readonly diagnosticsWaiters = new Map<string, Array<() => void>>();
|
|
244
283
|
private process: LanguageServerProcess | undefined;
|
|
245
284
|
private initializing: Promise<LanguageServerProcess> | undefined;
|
|
246
285
|
|
|
247
|
-
constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string) {
|
|
286
|
+
constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string, options: LspSymbolIndexOptions = {}) {
|
|
248
287
|
this.cwd = cwd;
|
|
249
288
|
this.descriptor = descriptor;
|
|
250
289
|
this.explicitSeedFile = seedFile;
|
|
290
|
+
this.maxOpenFiles = positiveLimit(options.maxOpenFiles, DEFAULT_MAX_OPEN_FILES, "maxOpenFiles");
|
|
291
|
+
this.maxFileBytes = positiveLimit(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, "maxFileBytes");
|
|
292
|
+
this.maxRefreshBytes = positiveLimit(options.maxRefreshBytes, 50 * 1024 * 1024, "maxRefreshBytes");
|
|
293
|
+
this.maxFallbackSeedFiles = positiveLimit(options.maxFallbackSeedFiles, 8, "maxFallbackSeedFiles");
|
|
294
|
+
const settleMs = descriptor.settleMs ?? DEFAULT_SETTLE_MS;
|
|
295
|
+
if (!Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > MAX_SETTLE_MS)
|
|
296
|
+
throw new TypeError(`settleMs must be an integer from 0 through ${MAX_SETTLE_MS}`);
|
|
297
|
+
this.provenance = {
|
|
298
|
+
fidelity: "semantic",
|
|
299
|
+
backend: descriptor.backendId,
|
|
300
|
+
languageId: descriptor.languageId,
|
|
301
|
+
authority: "language-server",
|
|
302
|
+
freshness: "live-process",
|
|
303
|
+
limitations: [],
|
|
304
|
+
};
|
|
251
305
|
}
|
|
252
306
|
|
|
253
307
|
/** Undefined before the server process has been spawned (first real query). */
|
|
@@ -258,11 +312,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
258
312
|
private async ensureInitialized(): Promise<LanguageServerProcess> {
|
|
259
313
|
if (this.process) return this.process;
|
|
260
314
|
if (!this.initializing) {
|
|
315
|
+
let spawned: LanguageServerProcess | undefined;
|
|
261
316
|
this.initializing = (async () => {
|
|
262
317
|
const proc = LanguageServerProcess.spawnProcess({
|
|
263
318
|
...resolveLanguageServerCommand(this.descriptor),
|
|
264
319
|
cwd: this.cwd,
|
|
265
320
|
});
|
|
321
|
+
spawned = proc;
|
|
266
322
|
proc.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
267
323
|
const { uri, diagnostics } = params as LspPublishDiagnosticsParams;
|
|
268
324
|
const path = fileURLToPath(uri);
|
|
@@ -294,24 +350,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
294
350
|
});
|
|
295
351
|
proc.notify("initialized", {});
|
|
296
352
|
|
|
297
|
-
const seedFile = this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
|
|
298
|
-
|
|
299
|
-
proc.notify("textDocument/didOpen", {
|
|
300
|
-
textDocument: {
|
|
301
|
-
uri: pathToFileURL(seedPath).href,
|
|
302
|
-
languageId: this.descriptor.languageId,
|
|
303
|
-
version: 1,
|
|
304
|
-
text: readFileSync(seedPath, "utf-8"),
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
this.openedFiles.add(seedPath);
|
|
308
|
-
// No server signals "project loaded"; must match ensureFileOpen's wait below,
|
|
309
|
-
// since the seed file is often the first file a caller queries.
|
|
310
|
-
await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
|
|
353
|
+
const seedFile = this.fallbackSeedFile ?? this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
|
|
354
|
+
await this.ensureFileOpen(proc, join(this.cwd, seedFile));
|
|
311
355
|
|
|
312
356
|
this.process = proc;
|
|
313
357
|
return proc;
|
|
314
|
-
})().catch((error: unknown) => {
|
|
358
|
+
})().catch(async (error: unknown) => {
|
|
359
|
+
await spawned?.stop();
|
|
315
360
|
// A failed initialize must not permanently poison this workspace's index --
|
|
316
361
|
// the next call retries fresh rather than replaying the same rejection forever.
|
|
317
362
|
this.initializing = undefined;
|
|
@@ -321,13 +366,37 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
321
366
|
return this.initializing;
|
|
322
367
|
}
|
|
323
368
|
|
|
324
|
-
|
|
369
|
+
private readBoundedFile(path: string): string {
|
|
370
|
+
const content = readFileSync(path);
|
|
371
|
+
if (content.byteLength > this.maxFileBytes) throw new LanguageFileLimitExceeded("file-bytes", this.maxFileBytes, content.byteLength);
|
|
372
|
+
return content.toString("utf-8");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Opens a file or sends a monotonic full-document change when its disk content changed since the prior query. */
|
|
325
376
|
private async ensureFileOpen(proc: LanguageServerProcess, path: string): Promise<void> {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
377
|
+
const content = this.readBoundedFile(path);
|
|
378
|
+
const opened = this.openedFiles.get(path);
|
|
379
|
+
if (opened?.content === content) return;
|
|
380
|
+
if (!opened) {
|
|
381
|
+
if (this.openedFiles.size >= this.maxOpenFiles) throw new LanguageFileLimitExceeded("open-files", this.maxOpenFiles, this.openedFiles.size + 1);
|
|
382
|
+
proc.notify("textDocument/didOpen", {
|
|
383
|
+
textDocument: {
|
|
384
|
+
uri: pathToFileURL(path).href,
|
|
385
|
+
languageId: this.descriptor.documentLanguageIds?.[extname(path)] ?? this.descriptor.languageId,
|
|
386
|
+
version: 1,
|
|
387
|
+
text: content,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
this.openedFiles.set(path, { version: 1, content });
|
|
391
|
+
} else {
|
|
392
|
+
const version = opened.version + 1;
|
|
393
|
+
this.latestDiagnostics.delete(path);
|
|
394
|
+
proc.notify("textDocument/didChange", {
|
|
395
|
+
textDocument: { uri: pathToFileURL(path).href, version },
|
|
396
|
+
contentChanges: [{ text: content }],
|
|
397
|
+
});
|
|
398
|
+
this.openedFiles.set(path, { version, content });
|
|
399
|
+
}
|
|
331
400
|
await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
|
|
332
401
|
}
|
|
333
402
|
|
|
@@ -344,10 +413,39 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
344
413
|
});
|
|
345
414
|
}
|
|
346
415
|
|
|
347
|
-
async
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
416
|
+
private async restartForSeed(seedFile: string): Promise<LanguageServerProcess> {
|
|
417
|
+
await this.process?.stop();
|
|
418
|
+
this.process = undefined;
|
|
419
|
+
this.initializing = undefined;
|
|
420
|
+
this.openedFiles.clear();
|
|
421
|
+
this.latestDiagnostics.clear();
|
|
422
|
+
this.diagnosticsWaiters.clear();
|
|
423
|
+
this.fallbackSeedFile = seedFile;
|
|
424
|
+
return this.ensureInitialized();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async findSymbols(query: string, bounds: SymbolSearchBounds = { maxResults: DEFAULT_MAX_SYMBOL_RESULTS }): Promise<SymbolSearchResult> {
|
|
428
|
+
if (!Number.isSafeInteger(bounds.maxResults) || bounds.maxResults < 1) throw new TypeError("maxResults must be a positive safe integer");
|
|
429
|
+
let proc = await this.ensureInitialized();
|
|
430
|
+
let refreshedBytes = 0;
|
|
431
|
+
for (const path of this.openedFiles.keys()) {
|
|
432
|
+
await this.ensureFileOpen(proc, path);
|
|
433
|
+
refreshedBytes += Buffer.byteLength(this.openedFiles.get(path)?.content ?? "", "utf-8");
|
|
434
|
+
if (refreshedBytes > this.maxRefreshBytes) throw new LanguageFileLimitExceeded("refresh-bytes", this.maxRefreshBytes, refreshedBytes);
|
|
435
|
+
}
|
|
436
|
+
let results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
|
|
437
|
+
if (results.length === 0 && this.descriptor.languageId === "typescript") {
|
|
438
|
+
const candidates = await new TypeScriptCompilerSymbolIndex(this.cwd, { maxResults: this.maxFallbackSeedFiles }).findSymbols(query, {
|
|
439
|
+
maxResults: this.maxFallbackSeedFiles,
|
|
440
|
+
});
|
|
441
|
+
const candidate = candidates.symbols[0];
|
|
442
|
+
if (candidate) {
|
|
443
|
+
proc = await this.restartForSeed(candidate.location.path);
|
|
444
|
+
results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const truncated = results.length > bounds.maxResults;
|
|
448
|
+
const symbols: WorkspaceSymbol[] = results.slice(0, bounds.maxResults).map((symbol) => ({
|
|
351
449
|
name: symbol.name,
|
|
352
450
|
kind: LSP_SYMBOL_KIND_NAMES[symbol.kind] ?? "unknown",
|
|
353
451
|
location: {
|
|
@@ -357,6 +455,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
357
455
|
},
|
|
358
456
|
containerName: symbol.containerName,
|
|
359
457
|
}));
|
|
458
|
+
return { symbols, truncated, provenance: this.provenance };
|
|
360
459
|
}
|
|
361
460
|
|
|
362
461
|
async goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
@@ -460,7 +559,9 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
|
460
559
|
}
|
|
461
560
|
|
|
462
561
|
async close(): Promise<void> {
|
|
463
|
-
|
|
562
|
+
const initializing = this.initializing;
|
|
563
|
+
const process = this.process ?? (initializing ? await initializing.catch(() => undefined) : undefined);
|
|
564
|
+
await process?.stop();
|
|
464
565
|
this.process = undefined;
|
|
465
566
|
this.initializing = undefined;
|
|
466
567
|
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
|
+
}
|