@danypops/lector 0.1.0
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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +48 -0
- package/src/adapters/directory-size.ts +19 -0
- package/src/adapters/find-source-files.ts +35 -0
- package/src/adapters/git-repo-fetcher.ts +146 -0
- package/src/adapters/in-memory-content-cache.ts +19 -0
- package/src/adapters/in-memory-search-cache.ts +32 -0
- package/src/adapters/in-memory-symbol-graph.ts +80 -0
- package/src/adapters/in-memory-workspace.ts +28 -0
- package/src/adapters/local-filesystem-workspace.ts +96 -0
- package/src/adapters/local-git.ts +73 -0
- package/src/adapters/lsp/discover-seed-file.ts +130 -0
- package/src/adapters/lsp/json-rpc-stream.ts +67 -0
- package/src/adapters/lsp/language-server-process.ts +184 -0
- package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
- package/src/adapters/lsp/process-resource-usage.ts +61 -0
- package/src/adapters/lsp/typescript-project-files.ts +51 -0
- package/src/adapters/read-only-workspace.ts +23 -0
- package/src/adapters/ripgrep-text-search.ts +97 -0
- package/src/adapters/source-manifest.ts +44 -0
- package/src/adapters/sqlite-content-cache.ts +67 -0
- package/src/adapters/sqlite-search-cache.ts +60 -0
- package/src/adapters/sqlite-symbol-graph.ts +154 -0
- package/src/adapters/tiered-search-cache.ts +29 -0
- package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
- package/src/cli.ts +777 -0
- package/src/client.ts +63 -0
- package/src/constants.ts +16 -0
- package/src/daemon.ts +166 -0
- package/src/domain/assert-safe-git-argument.ts +19 -0
- package/src/domain/assert-safe-path-segment.ts +17 -0
- package/src/domain/assert-safe-repo-reference.ts +20 -0
- package/src/domain/assert-safe-search-query.ts +17 -0
- package/src/domain/bounded-job-executor.ts +219 -0
- package/src/domain/call-hierarchy.ts +23 -0
- package/src/domain/code-range.ts +11 -0
- package/src/domain/content-hash.ts +14 -0
- package/src/domain/diagnostic.ts +12 -0
- package/src/domain/diagnostics.ts +7 -0
- package/src/domain/document-symbol.ts +19 -0
- package/src/domain/document-symbols.ts +7 -0
- package/src/domain/exact-edit.ts +43 -0
- package/src/domain/find-references.ts +7 -0
- package/src/domain/find-workspace-symbols.ts +7 -0
- package/src/domain/git-diff-result.ts +5 -0
- package/src/domain/git-log-entry.ts +9 -0
- package/src/domain/git-status.ts +17 -0
- package/src/domain/go-to-definition.ts +7 -0
- package/src/domain/go-to-implementation.ts +7 -0
- package/src/domain/hover-at.ts +8 -0
- package/src/domain/hover.ts +7 -0
- package/src/domain/incoming-calls.ts +8 -0
- package/src/domain/language-server-descriptor.ts +122 -0
- package/src/domain/outgoing-calls.ts +8 -0
- package/src/domain/populate-symbol-graph.ts +91 -0
- package/src/domain/prepare-call-hierarchy.ts +8 -0
- package/src/domain/race-workspace-query.ts +39 -0
- package/src/domain/raw-read.ts +24 -0
- package/src/domain/reachable-symbols-from.ts +13 -0
- package/src/domain/repo-fetch-result.ts +18 -0
- package/src/domain/repo-reference.ts +7 -0
- package/src/domain/search-cache-key.ts +16 -0
- package/src/domain/search-text.ts +29 -0
- package/src/domain/symbol-edges-from.ts +9 -0
- package/src/domain/symbol-edges-to.ts +9 -0
- package/src/domain/symbol-graph-generation.ts +14 -0
- package/src/domain/symbol-node-id.ts +13 -0
- package/src/domain/text-search-result.ts +15 -0
- package/src/domain/workspace-query-outcome.ts +24 -0
- package/src/domain/workspace-symbol.ts +14 -0
- package/src/index.ts +121 -0
- package/src/ports/code-intelligence-port.ts +38 -0
- package/src/ports/content-cache-port.ts +52 -0
- package/src/ports/git-port.ts +23 -0
- package/src/ports/repo-fetcher-port.ts +11 -0
- package/src/ports/search-cache-port.ts +15 -0
- package/src/ports/symbol-graph-port.ts +35 -0
- package/src/ports/symbol-index-port.ts +11 -0
- package/src/ports/text-search-port.ts +12 -0
- package/src/ports/workspace-port.ts +35 -0
- package/src/service.ts +961 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../../domain/call-hierarchy.ts";
|
|
5
|
+
import type { CodeRange } from "../../domain/code-range.ts";
|
|
6
|
+
import type { Diagnostic, DiagnosticSeverity } from "../../domain/diagnostic.ts";
|
|
7
|
+
import type { DocumentSymbolEntry } from "../../domain/document-symbol.ts";
|
|
8
|
+
import type { Hover } from "../../domain/hover.ts";
|
|
9
|
+
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 { CodeIntelligencePort } from "../../ports/code-intelligence-port.ts";
|
|
12
|
+
import type { SymbolIndexPort } from "../../ports/symbol-index-port.ts";
|
|
13
|
+
import { resolveSeedFile } from "./discover-seed-file.ts";
|
|
14
|
+
import { LanguageServerProcess } from "./language-server-process.ts";
|
|
15
|
+
|
|
16
|
+
const LSP_SYMBOL_KIND_NAMES: Readonly<Record<number, string>> = {
|
|
17
|
+
1: "file",
|
|
18
|
+
2: "module",
|
|
19
|
+
3: "namespace",
|
|
20
|
+
4: "package",
|
|
21
|
+
5: "class",
|
|
22
|
+
6: "method",
|
|
23
|
+
7: "property",
|
|
24
|
+
8: "field",
|
|
25
|
+
9: "constructor",
|
|
26
|
+
10: "enum",
|
|
27
|
+
11: "interface",
|
|
28
|
+
12: "function",
|
|
29
|
+
13: "variable",
|
|
30
|
+
14: "constant",
|
|
31
|
+
15: "string",
|
|
32
|
+
16: "number",
|
|
33
|
+
17: "boolean",
|
|
34
|
+
18: "array",
|
|
35
|
+
19: "object",
|
|
36
|
+
20: "key",
|
|
37
|
+
21: "null",
|
|
38
|
+
22: "enum-member",
|
|
39
|
+
23: "struct",
|
|
40
|
+
24: "event",
|
|
41
|
+
25: "operator",
|
|
42
|
+
26: "type-parameter",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface LspSymbolInformation {
|
|
46
|
+
name: string;
|
|
47
|
+
kind: number;
|
|
48
|
+
location: { uri: string; range: { start: { line: number; character: number } } };
|
|
49
|
+
containerName?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface LspPosition {
|
|
53
|
+
line: number;
|
|
54
|
+
character: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LspRange {
|
|
58
|
+
start: LspPosition;
|
|
59
|
+
end: LspPosition;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface LspLocation {
|
|
63
|
+
uri: string;
|
|
64
|
+
range: LspRange;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface LspLocationLink {
|
|
68
|
+
targetUri: string;
|
|
69
|
+
targetRange: LspRange;
|
|
70
|
+
targetSelectionRange: LspRange;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface LspHover {
|
|
74
|
+
contents: string | { language: string; value: string } | Array<string | { language: string; value: string }> | { kind: string; value: string };
|
|
75
|
+
range?: LspRange;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface LspDocumentSymbol {
|
|
79
|
+
name: string;
|
|
80
|
+
detail?: string;
|
|
81
|
+
kind: number;
|
|
82
|
+
range: LspRange;
|
|
83
|
+
selectionRange: LspRange;
|
|
84
|
+
children?: LspDocumentSymbol[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface LspDiagnostic {
|
|
88
|
+
range: LspRange;
|
|
89
|
+
severity?: number;
|
|
90
|
+
code?: string | number;
|
|
91
|
+
source?: string;
|
|
92
|
+
message: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface LspCallHierarchyItem {
|
|
96
|
+
name: string;
|
|
97
|
+
kind: number;
|
|
98
|
+
detail?: string;
|
|
99
|
+
uri: string;
|
|
100
|
+
range: LspRange;
|
|
101
|
+
selectionRange: LspRange;
|
|
102
|
+
/** Opaque, server-defined; must be passed back verbatim to callHierarchy/incomingCalls|outgoingCalls. */
|
|
103
|
+
data?: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface LspCallHierarchyIncomingCall {
|
|
107
|
+
from: LspCallHierarchyItem;
|
|
108
|
+
fromRanges: LspRange[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface LspCallHierarchyOutgoingCall {
|
|
112
|
+
to: LspCallHierarchyItem;
|
|
113
|
+
fromRanges: LspRange[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface LspPublishDiagnosticsParams {
|
|
117
|
+
uri: string;
|
|
118
|
+
diagnostics: LspDiagnostic[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** LSP DiagnosticSeverity is 1-4; the spec recommends treating an absent severity as an error. */
|
|
122
|
+
const LSP_DIAGNOSTIC_SEVERITY_NAMES: Readonly<Record<number, DiagnosticSeverity>> = {
|
|
123
|
+
1: "error",
|
|
124
|
+
2: "warning",
|
|
125
|
+
3: "information",
|
|
126
|
+
4: "hint",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Discriminates the two possible textDocument/documentSymbol response item shapes -- only DocumentSymbol carries `range`. */
|
|
130
|
+
function isHierarchicalDocumentSymbol(item: LspDocumentSymbol | LspSymbolInformation): item is LspDocumentSymbol {
|
|
131
|
+
return "range" in item;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Resolves a descriptor's launch into a spawnable command + args. */
|
|
135
|
+
function resolveLanguageServerCommand(descriptor: LanguageServerDescriptor): { command: string; args: string[] } {
|
|
136
|
+
if (descriptor.launch.kind === "npm-module") {
|
|
137
|
+
return { command: "bun", args: [fileURLToPath(import.meta.resolve(descriptor.launch.entryModule)), ...descriptor.args] };
|
|
138
|
+
}
|
|
139
|
+
return { command: descriptor.launch.command, args: [...descriptor.args] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** LSP positions are 0-indexed; WorkspaceLocation/CodeRange are 1-indexed (doc convention: humans and CLIs present positions 1-indexed). */
|
|
143
|
+
function toLspPosition(line: number, character: number): LspPosition {
|
|
144
|
+
return { line: line - 1, character: character - 1 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fromLspPosition(position: LspPosition): { line: number; character: number } {
|
|
148
|
+
return { line: position.line + 1, character: position.character + 1 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function toWorkspaceLocation(uri: string, position: LspPosition): WorkspaceLocation {
|
|
152
|
+
const { line, character } = fromLspPosition(position);
|
|
153
|
+
return { path: fileURLToPath(uri), line, character };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toCodeRange(path: string, range: LspRange): CodeRange {
|
|
157
|
+
return { path, start: fromLspPosition(range.start), end: fromLspPosition(range.end) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isLocationLink(item: LspLocation | LspLocationLink): item is LspLocationLink {
|
|
161
|
+
return "targetUri" in item;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Normalizes definition/declaration/typeDefinition/implementation's Location | Location[] | LocationLink[] | null into one shape. */
|
|
165
|
+
function normalizeLocations(result: LspLocation | LspLocation[] | LspLocationLink[] | null): WorkspaceLocation[] {
|
|
166
|
+
if (!result) return [];
|
|
167
|
+
const items = Array.isArray(result) ? result : [result];
|
|
168
|
+
return items.map((item) =>
|
|
169
|
+
isLocationLink(item) ? toWorkspaceLocation(item.targetUri, item.targetSelectionRange.start) : toWorkspaceLocation(item.uri, item.range.start),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Flattens Hover.contents' three possible LSP shapes (MarkupContent, MarkedString, MarkedString[]) to one string. */
|
|
174
|
+
function normalizeHoverContents(contents: LspHover["contents"]): string {
|
|
175
|
+
if (typeof contents === "string") return contents;
|
|
176
|
+
if (Array.isArray(contents)) return contents.map((item) => (typeof item === "string" ? item : `\`\`\`${item.language}\n${item.value}\n\`\`\``)).join("\n\n");
|
|
177
|
+
if ("language" in contents) return `\`\`\`${contents.language}\n${contents.value}\n\`\`\``;
|
|
178
|
+
return contents.value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeCallHierarchyItem(item: LspCallHierarchyItem): CallHierarchyEntry {
|
|
182
|
+
const path = fileURLToPath(item.uri);
|
|
183
|
+
return {
|
|
184
|
+
name: item.name,
|
|
185
|
+
kind: LSP_SYMBOL_KIND_NAMES[item.kind] ?? "unknown",
|
|
186
|
+
detail: item.detail,
|
|
187
|
+
location: toWorkspaceLocation(item.uri, item.selectionRange.start),
|
|
188
|
+
range: toCodeRange(path, item.range),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeDiagnostic(path: string, item: LspDiagnostic): Diagnostic {
|
|
193
|
+
return {
|
|
194
|
+
range: toCodeRange(path, item.range),
|
|
195
|
+
severity: LSP_DIAGNOSTIC_SEVERITY_NAMES[item.severity ?? 1] ?? "error",
|
|
196
|
+
message: item.message,
|
|
197
|
+
source: item.source,
|
|
198
|
+
code: item.code,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeDocumentSymbol(path: string, item: LspDocumentSymbol | LspSymbolInformation): DocumentSymbolEntry {
|
|
203
|
+
const kind = LSP_SYMBOL_KIND_NAMES[item.kind] ?? "unknown";
|
|
204
|
+
if (isHierarchicalDocumentSymbol(item)) {
|
|
205
|
+
return {
|
|
206
|
+
name: item.name,
|
|
207
|
+
kind,
|
|
208
|
+
detail: item.detail,
|
|
209
|
+
range: toCodeRange(path, item.range),
|
|
210
|
+
selectionRange: toCodeRange(path, item.selectionRange),
|
|
211
|
+
children: item.children?.map((child) => normalizeDocumentSymbol(path, child)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// Flat SymbolInformation fallback (a server that ignored hierarchicalDocumentSymbolSupport): no real
|
|
215
|
+
// selectionRange exists, so range and selectionRange both point at the same location -- not fabricated
|
|
216
|
+
// precision, an honest "this is all the server gave us."
|
|
217
|
+
const range = toCodeRange(path, { start: item.location.range.start, end: item.location.range.start });
|
|
218
|
+
return { name: item.name, kind, range, selectionRange: range };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* SymbolIndexPort + CodeIntelligencePort over one warm LSP server process per
|
|
223
|
+
* workspace, driven by a LanguageServerDescriptor rather than a hardcoded
|
|
224
|
+
* language -- the same class serves TypeScript, Python, or any future
|
|
225
|
+
* language whose descriptor is supplied. Lazily spawned on first use; the
|
|
226
|
+
* caller closes it.
|
|
227
|
+
*
|
|
228
|
+
* A real LSP server only answers `workspace/symbol` for a project it has
|
|
229
|
+
* loaded, and a project loads only once one of its files is opened via
|
|
230
|
+
* `didOpen` -- `seedFile` names that file (auto-picked via discoverSeedFile()
|
|
231
|
+
* when omitted). Position/file-based operations need their own target file
|
|
232
|
+
* opened the same way, plus a settle wait (descriptor.settleMs) before the
|
|
233
|
+
* server answers accurately about it; most servers have no "fully checked"
|
|
234
|
+
* signal to poll instead. findReferences only searches files the server has
|
|
235
|
+
* actually loaded -- open a file first to guarantee its usages are included.
|
|
236
|
+
*/
|
|
237
|
+
export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
|
|
238
|
+
private readonly cwd: string;
|
|
239
|
+
private readonly descriptor: LanguageServerDescriptor;
|
|
240
|
+
private readonly explicitSeedFile: string | undefined;
|
|
241
|
+
private readonly openedFiles = new Set<string>();
|
|
242
|
+
private readonly latestDiagnostics = new Map<string, Diagnostic[]>();
|
|
243
|
+
private readonly diagnosticsWaiters = new Map<string, Array<() => void>>();
|
|
244
|
+
private process: LanguageServerProcess | undefined;
|
|
245
|
+
private initializing: Promise<LanguageServerProcess> | undefined;
|
|
246
|
+
|
|
247
|
+
constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string) {
|
|
248
|
+
this.cwd = cwd;
|
|
249
|
+
this.descriptor = descriptor;
|
|
250
|
+
this.explicitSeedFile = seedFile;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Undefined before the server process has been spawned (first real query). */
|
|
254
|
+
get processId(): number | undefined {
|
|
255
|
+
return this.process?.pid;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async ensureInitialized(): Promise<LanguageServerProcess> {
|
|
259
|
+
if (this.process) return this.process;
|
|
260
|
+
if (!this.initializing) {
|
|
261
|
+
this.initializing = (async () => {
|
|
262
|
+
const proc = LanguageServerProcess.spawnProcess({
|
|
263
|
+
...resolveLanguageServerCommand(this.descriptor),
|
|
264
|
+
cwd: this.cwd,
|
|
265
|
+
});
|
|
266
|
+
proc.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
267
|
+
const { uri, diagnostics } = params as LspPublishDiagnosticsParams;
|
|
268
|
+
const path = fileURLToPath(uri);
|
|
269
|
+
this.latestDiagnostics.set(
|
|
270
|
+
path,
|
|
271
|
+
diagnostics.map((item) => normalizeDiagnostic(path, item)),
|
|
272
|
+
);
|
|
273
|
+
const waiters = this.diagnosticsWaiters.get(path);
|
|
274
|
+
if (waiters) {
|
|
275
|
+
this.diagnosticsWaiters.delete(path);
|
|
276
|
+
for (const resolve of waiters) resolve();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
await proc.request("initialize", {
|
|
280
|
+
processId: process.pid,
|
|
281
|
+
rootUri: pathToFileURL(this.cwd).href,
|
|
282
|
+
// pyright needs this to resolve its own workspace root -- without it, workspace/symbol
|
|
283
|
+
// silently returns [] forever, even though rootUri alone is enough for tsserver/gopls.
|
|
284
|
+
workspaceFolders: [{ uri: pathToFileURL(this.cwd).href, name: this.cwd }],
|
|
285
|
+
capabilities: {
|
|
286
|
+
textDocument: {
|
|
287
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
288
|
+
definition: { linkSupport: true },
|
|
289
|
+
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
290
|
+
...this.descriptor.extraCapabilities,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
initializationOptions: {},
|
|
294
|
+
});
|
|
295
|
+
proc.notify("initialized", {});
|
|
296
|
+
|
|
297
|
+
const seedFile = this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
|
|
298
|
+
const seedPath = join(this.cwd, seedFile);
|
|
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));
|
|
311
|
+
|
|
312
|
+
this.process = proc;
|
|
313
|
+
return proc;
|
|
314
|
+
})().catch((error: unknown) => {
|
|
315
|
+
// A failed initialize must not permanently poison this workspace's index --
|
|
316
|
+
// the next call retries fresh rather than replaying the same rejection forever.
|
|
317
|
+
this.initializing = undefined;
|
|
318
|
+
throw error;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return this.initializing;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Opens `path` with the server if not already open, then waits for it to settle. A no-op past the first call for a given path. */
|
|
325
|
+
private async ensureFileOpen(proc: LanguageServerProcess, path: string): Promise<void> {
|
|
326
|
+
if (this.openedFiles.has(path)) return;
|
|
327
|
+
proc.notify("textDocument/didOpen", {
|
|
328
|
+
textDocument: { uri: pathToFileURL(path).href, languageId: this.descriptor.languageId, version: 1, text: readFileSync(path, "utf-8") },
|
|
329
|
+
});
|
|
330
|
+
this.openedFiles.add(path);
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Resolves as soon as `path`'s next publishDiagnostics notification lands, or after `timeoutMs` -- diagnostics are server-pushed, never a request/response Lector can just await. */
|
|
335
|
+
private waitForDiagnosticsNotification(path: string, timeoutMs: number): Promise<void> {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
338
|
+
const waiters = this.diagnosticsWaiters.get(path) ?? [];
|
|
339
|
+
waiters.push(() => {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
resolve();
|
|
342
|
+
});
|
|
343
|
+
this.diagnosticsWaiters.set(path, waiters);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async findSymbols(query: string): Promise<WorkspaceSymbol[]> {
|
|
348
|
+
const proc = await this.ensureInitialized();
|
|
349
|
+
const results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
|
|
350
|
+
return results.map((symbol) => ({
|
|
351
|
+
name: symbol.name,
|
|
352
|
+
kind: LSP_SYMBOL_KIND_NAMES[symbol.kind] ?? "unknown",
|
|
353
|
+
location: {
|
|
354
|
+
path: fileURLToPath(symbol.location.uri),
|
|
355
|
+
line: symbol.location.range.start.line + 1,
|
|
356
|
+
character: symbol.location.range.start.character + 1,
|
|
357
|
+
},
|
|
358
|
+
containerName: symbol.containerName,
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
363
|
+
const proc = await this.ensureInitialized();
|
|
364
|
+
await this.ensureFileOpen(proc, at.path);
|
|
365
|
+
const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/definition", {
|
|
366
|
+
textDocument: { uri: pathToFileURL(at.path).href },
|
|
367
|
+
position: toLspPosition(at.line, at.character),
|
|
368
|
+
});
|
|
369
|
+
return normalizeLocations(result);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async goToImplementation(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
373
|
+
const proc = await this.ensureInitialized();
|
|
374
|
+
await this.ensureFileOpen(proc, at.path);
|
|
375
|
+
const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/implementation", {
|
|
376
|
+
textDocument: { uri: pathToFileURL(at.path).href },
|
|
377
|
+
position: toLspPosition(at.line, at.character),
|
|
378
|
+
});
|
|
379
|
+
return normalizeLocations(result);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async findReferences(at: WorkspaceLocation, includeDeclaration: boolean): Promise<WorkspaceLocation[]> {
|
|
383
|
+
const proc = await this.ensureInitialized();
|
|
384
|
+
await this.ensureFileOpen(proc, at.path);
|
|
385
|
+
const results =
|
|
386
|
+
(await proc.request<LspLocation[] | null>("textDocument/references", {
|
|
387
|
+
textDocument: { uri: pathToFileURL(at.path).href },
|
|
388
|
+
position: toLspPosition(at.line, at.character),
|
|
389
|
+
context: { includeDeclaration },
|
|
390
|
+
})) ?? [];
|
|
391
|
+
return results.map((location) => toWorkspaceLocation(location.uri, location.range.start));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async hover(at: WorkspaceLocation): Promise<Hover | undefined> {
|
|
395
|
+
const proc = await this.ensureInitialized();
|
|
396
|
+
await this.ensureFileOpen(proc, at.path);
|
|
397
|
+
const result = await proc.request<LspHover | null>("textDocument/hover", {
|
|
398
|
+
textDocument: { uri: pathToFileURL(at.path).href },
|
|
399
|
+
position: toLspPosition(at.line, at.character),
|
|
400
|
+
});
|
|
401
|
+
if (!result) return undefined;
|
|
402
|
+
return { contents: normalizeHoverContents(result.contents), range: result.range ? toCodeRange(at.path, result.range) : undefined };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async documentSymbols(path: string): Promise<DocumentSymbolEntry[]> {
|
|
406
|
+
const proc = await this.ensureInitialized();
|
|
407
|
+
await this.ensureFileOpen(proc, path);
|
|
408
|
+
const results =
|
|
409
|
+
(await proc.request<(LspDocumentSymbol | LspSymbolInformation)[] | null>("textDocument/documentSymbol", {
|
|
410
|
+
textDocument: { uri: pathToFileURL(path).href },
|
|
411
|
+
})) ?? [];
|
|
412
|
+
return results.map((item) => normalizeDocumentSymbol(path, item));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async diagnostics(path: string): Promise<Diagnostic[]> {
|
|
416
|
+
const proc = await this.ensureInitialized();
|
|
417
|
+
await this.ensureFileOpen(proc, path);
|
|
418
|
+
if (!this.latestDiagnostics.has(path)) await this.waitForDiagnosticsNotification(path, 5000);
|
|
419
|
+
return this.latestDiagnostics.get(path) ?? [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Raw LSP items, `data` intact -- callHierarchy/incomingCalls|outgoingCalls need the exact item prepareCallHierarchy returned, not a normalized copy. */
|
|
423
|
+
private async prepareCallHierarchyRaw(proc: LanguageServerProcess, at: WorkspaceLocation): Promise<LspCallHierarchyItem[]> {
|
|
424
|
+
await this.ensureFileOpen(proc, at.path);
|
|
425
|
+
const result = await proc.request<LspCallHierarchyItem[] | null>("textDocument/prepareCallHierarchy", {
|
|
426
|
+
textDocument: { uri: pathToFileURL(at.path).href },
|
|
427
|
+
position: toLspPosition(at.line, at.character),
|
|
428
|
+
});
|
|
429
|
+
return result ?? [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async prepareCallHierarchy(at: WorkspaceLocation): Promise<CallHierarchyEntry[]> {
|
|
433
|
+
const proc = await this.ensureInitialized();
|
|
434
|
+
const items = await this.prepareCallHierarchyRaw(proc, at);
|
|
435
|
+
return items.map((item) => normalizeCallHierarchyItem(item));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async incomingCalls(at: WorkspaceLocation): Promise<IncomingCall[]> {
|
|
439
|
+
const proc = await this.ensureInitialized();
|
|
440
|
+
const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
|
|
441
|
+
if (!root) return [];
|
|
442
|
+
const results = (await proc.request<LspCallHierarchyIncomingCall[] | null>("callHierarchy/incomingCalls", { item: root })) ?? [];
|
|
443
|
+
return results.map((result) => ({
|
|
444
|
+
from: normalizeCallHierarchyItem(result.from),
|
|
445
|
+
fromRanges: result.fromRanges.map((range) => toCodeRange(fileURLToPath(result.from.uri), range)),
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async outgoingCalls(at: WorkspaceLocation): Promise<OutgoingCall[]> {
|
|
450
|
+
const proc = await this.ensureInitialized();
|
|
451
|
+
const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
|
|
452
|
+
if (!root) return [];
|
|
453
|
+
const results = (await proc.request<LspCallHierarchyOutgoingCall[] | null>("callHierarchy/outgoingCalls", { item: root })) ?? [];
|
|
454
|
+
// fromRanges here are relative to `root` (the item passed to the request), per spec -- not `to`.
|
|
455
|
+
const rootPath = fileURLToPath(root.uri);
|
|
456
|
+
return results.map((result) => ({
|
|
457
|
+
to: normalizeCallHierarchyItem(result.to),
|
|
458
|
+
fromRanges: result.fromRanges.map((range) => toCodeRange(rootPath, range)),
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async close(): Promise<void> {
|
|
463
|
+
await this.process?.stop();
|
|
464
|
+
this.process = undefined;
|
|
465
|
+
this.initializing = undefined;
|
|
466
|
+
this.openedFiles.clear();
|
|
467
|
+
this.latestDiagnostics.clear();
|
|
468
|
+
this.diagnosticsWaiters.clear();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Real RSS memory (kilobytes) a process and every one of its descendants
|
|
5
|
+
* currently hold, read from /proc -- a server like typescript-language-server
|
|
6
|
+
* spawns tsserver as a child process that would otherwise go uncounted.
|
|
7
|
+
* Returns undefined where /proc is unavailable (non-Linux), never throws.
|
|
8
|
+
*/
|
|
9
|
+
export function measureProcessTreeRssKb(rootPid: number): number | undefined {
|
|
10
|
+
if (!existsSync("/proc")) return undefined;
|
|
11
|
+
|
|
12
|
+
const childrenByParent = buildParentIndex();
|
|
13
|
+
const stack = [rootPid];
|
|
14
|
+
const seen = new Set<number>();
|
|
15
|
+
let totalKb = 0;
|
|
16
|
+
|
|
17
|
+
while (stack.length > 0) {
|
|
18
|
+
const pid = stack.pop();
|
|
19
|
+
if (pid === undefined || seen.has(pid)) continue;
|
|
20
|
+
seen.add(pid);
|
|
21
|
+
totalKb += readRssKb(pid) ?? 0;
|
|
22
|
+
for (const child of childrenByParent.get(pid) ?? []) stack.push(child);
|
|
23
|
+
}
|
|
24
|
+
return totalKb;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readRssKb(pid: number): number | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf-8");
|
|
30
|
+
const match = status.match(/^VmRSS:\s+(\d+) kB$/m);
|
|
31
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildParentIndex(): Map<number, number[]> {
|
|
38
|
+
const index = new Map<number, number[]>();
|
|
39
|
+
let entries: string[];
|
|
40
|
+
try {
|
|
41
|
+
entries = readdirSync("/proc");
|
|
42
|
+
} catch {
|
|
43
|
+
return index;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const pid = Number(entry);
|
|
47
|
+
if (!Number.isInteger(pid)) continue;
|
|
48
|
+
try {
|
|
49
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf-8");
|
|
50
|
+
const match = status.match(/^PPid:\s+(\d+)$/m);
|
|
51
|
+
const ppid = match?.[1] ? Number(match[1]) : undefined;
|
|
52
|
+
if (ppid === undefined) continue;
|
|
53
|
+
const siblings = index.get(ppid);
|
|
54
|
+
if (siblings) siblings.push(pid);
|
|
55
|
+
else index.set(ppid, [pid]);
|
|
56
|
+
} catch {
|
|
57
|
+
// Process exited between readdir and read -- not a real descendant to count.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return index;
|
|
61
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Bun resolves the bare specifier "typescript" from a directly-executed .ts entrypoint to an
|
|
5
|
+
// unrelated global stub (`~/.cache/.bun/install/cache/typescript@<version>/lib/version.cjs`,
|
|
6
|
+
// ~2 exports, no `sys`/`findConfigFile`/etc) -- confirmed empirically, not assumed: `require`,
|
|
7
|
+
// `import`, and `import.meta.resolve` on the bare specifier all hit it, only a deep import path
|
|
8
|
+
// resolves the real, project-local package. This is a real, working typescript package once
|
|
9
|
+
// resolved this way, not the version that bun hijacks.
|
|
10
|
+
const ts = createRequire(import.meta.url)("typescript/lib/typescript.js") as typeof import("typescript");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Refines a seed-file candidate against TypeScript's own real project-file resolution
|
|
14
|
+
* (`findConfigFile` + `parseJsonConfigFileContent`, the same functions tsserver itself uses) --
|
|
15
|
+
* a candidate merely sitting *under* a directory that has a tsconfig.json is not sufficient:
|
|
16
|
+
* that tsconfig's own include/exclude may not actually cover it (found empirically against this
|
|
17
|
+
* project's own monorepo: a seed file picked from `benchmarks/` had a real tsconfig.json two
|
|
18
|
+
* directories up, but that tsconfig's `include: ["src", "test"]` never covered it, so
|
|
19
|
+
* workspace/symbol silently searched the wrong, near-empty inferred project). Falls back to the
|
|
20
|
+
* original candidate if no tsconfig is found at all, or parsing fails -- honest degradation to
|
|
21
|
+
* the pre-refinement heuristic, not a hard failure.
|
|
22
|
+
*/
|
|
23
|
+
export function refineTypescriptSeedFile(rootPath: string, candidateRelativePath: string): string {
|
|
24
|
+
try {
|
|
25
|
+
// Searched from the candidate's own directory, not rootPath -- tsserver associates a file
|
|
26
|
+
// with the nearest enclosing tsconfig to *that file*, which in a monorepo is almost always
|
|
27
|
+
// several directories below the registered workspace root (e.g. packages/lector/tsconfig.json,
|
|
28
|
+
// not a root tsconfig.json that may not even exist).
|
|
29
|
+
const candidateDir = join(rootPath, dirname(candidateRelativePath));
|
|
30
|
+
const configPath = ts.findConfigFile(candidateDir, ts.sys.fileExists);
|
|
31
|
+
if (!configPath) return candidateRelativePath;
|
|
32
|
+
|
|
33
|
+
const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
34
|
+
if (error) return candidateRelativePath;
|
|
35
|
+
|
|
36
|
+
// include/exclude are resolved relative to the config file's own directory, not rootPath.
|
|
37
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirname(configPath));
|
|
38
|
+
if (parsed.fileNames.length === 0) return candidateRelativePath;
|
|
39
|
+
|
|
40
|
+
const candidateAbsolute = join(rootPath, candidateRelativePath);
|
|
41
|
+
if (parsed.fileNames.includes(candidateAbsolute)) return candidateRelativePath;
|
|
42
|
+
|
|
43
|
+
const firstRealFile = parsed.fileNames[0];
|
|
44
|
+
if (!firstRealFile) return candidateRelativePath;
|
|
45
|
+
const relativeToRoot = relative(rootPath, firstRealFile);
|
|
46
|
+
if (relativeToRoot.startsWith("..")) return candidateRelativePath;
|
|
47
|
+
return relativeToRoot;
|
|
48
|
+
} catch {
|
|
49
|
+
return candidateRelativePath;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ContentHash } from "../domain/content-hash.ts";
|
|
2
|
+
import type { WorkspaceEntry, WorkspacePort } from "../ports/workspace-port.ts";
|
|
3
|
+
|
|
4
|
+
/** Raised when a write targets a workspace the caller doesn't own, like a RepoFetcherPort checkout. */
|
|
5
|
+
export class WorkspaceIsReadOnly extends Error {
|
|
6
|
+
constructor(readonly path: string) {
|
|
7
|
+
super(`workspace is read-only; cannot write "${path}" -- it's a foreign checkout, not one the caller owns`);
|
|
8
|
+
this.name = "WorkspaceIsReadOnly";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Wraps any WorkspacePort to reject every write. Reads pass through unchanged. */
|
|
13
|
+
export class ReadOnlyWorkspace implements WorkspacePort {
|
|
14
|
+
constructor(private readonly inner: WorkspacePort) {}
|
|
15
|
+
|
|
16
|
+
readEntry(path: string): Promise<WorkspaceEntry> {
|
|
17
|
+
return this.inner.readEntry(path);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async writeEntry(path: string, _expectedHash: ContentHash | null, _content: string): Promise<{ previousHash: ContentHash | null; newHash: ContentHash }> {
|
|
21
|
+
throw new WorkspaceIsReadOnly(path);
|
|
22
|
+
}
|
|
23
|
+
}
|