pi-lens 2.0.7 → 2.0.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.
Files changed (34) hide show
  1. package/clients/ast-grep-client.test.ts +146 -116
  2. package/clients/ast-grep-client.ts +645 -551
  3. package/clients/biome-client.test.ts +154 -137
  4. package/clients/biome-client.ts +397 -337
  5. package/clients/complexity-client.test.ts +188 -200
  6. package/clients/complexity-client.ts +815 -667
  7. package/clients/dependency-checker.test.ts +55 -55
  8. package/clients/dependency-checker.ts +358 -333
  9. package/clients/go-client.test.ts +121 -111
  10. package/clients/go-client.ts +218 -216
  11. package/clients/jscpd-client.test.ts +132 -132
  12. package/clients/jscpd-client.ts +155 -118
  13. package/clients/knip-client.test.ts +123 -133
  14. package/clients/knip-client.ts +231 -218
  15. package/clients/metrics-client.test.ts +171 -167
  16. package/clients/metrics-client.ts +283 -252
  17. package/clients/ruff-client.test.ts +128 -117
  18. package/clients/ruff-client.ts +300 -269
  19. package/clients/rust-client.test.ts +104 -85
  20. package/clients/rust-client.ts +241 -234
  21. package/clients/subprocess-client.ts +1 -1
  22. package/clients/test-runner-client.test.ts +248 -215
  23. package/clients/test-runner-client.ts +728 -608
  24. package/clients/test-utils.ts +10 -3
  25. package/clients/todo-scanner.test.ts +288 -202
  26. package/clients/todo-scanner.ts +225 -187
  27. package/clients/type-coverage-client.test.ts +119 -119
  28. package/clients/type-coverage-client.ts +142 -115
  29. package/clients/types.ts +28 -28
  30. package/clients/typescript-client.test.ts +99 -93
  31. package/clients/typescript-client.ts +527 -502
  32. package/index.ts +662 -212
  33. package/package.json +1 -1
  34. package/tsconfig.json +12 -12
@@ -5,17 +5,17 @@
5
5
  * This is lighter weight than spawning tsserver and provides the same features.
6
6
  */
7
7
 
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
8
10
  import * as ts from "typescript";
9
- import * as path from "path";
10
- import * as fs from "fs";
11
11
  import type {
12
- Diagnostic,
13
- DiagnosticSeverity,
14
- SymbolInfo,
15
- HoverInfo,
16
- Location,
17
- CompletionItem,
18
- FoldingRange,
12
+ CompletionItem,
13
+ Diagnostic,
14
+ DiagnosticSeverity,
15
+ FoldingRange,
16
+ HoverInfo,
17
+ Location,
18
+ SymbolInfo,
19
19
  } from "./types.js";
20
20
 
21
21
  // TypeScript file extensions
@@ -29,35 +29,40 @@ const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
29
29
  * Language Service looks up those names relative to cwd, not the TS install dir.
30
30
  */
31
31
  function buildDefaultCompilerOptions(): ts.CompilerOptions {
32
- const fakeConfig = {
33
- compilerOptions: {
34
- target: "ES2020",
35
- module: "ESNext",
36
- moduleResolution: "bundler",
37
- strict: true,
38
- esModuleInterop: true,
39
- skipLibCheck: true,
40
- lib: ["es2020", "dom", "dom.iterable"],
41
- },
42
- };
43
- const parsed = ts.parseJsonConfigFileContent(fakeConfig, ts.sys, process.cwd());
44
- return { ...parsed.options, skipLibCheck: true };
32
+ const fakeConfig = {
33
+ compilerOptions: {
34
+ target: "ES2020",
35
+ module: "ESNext",
36
+ moduleResolution: "bundler",
37
+ strict: true,
38
+ esModuleInterop: true,
39
+ skipLibCheck: true,
40
+ lib: ["es2020", "dom", "dom.iterable"],
41
+ },
42
+ };
43
+ const parsed = ts.parseJsonConfigFileContent(
44
+ fakeConfig,
45
+ ts.sys,
46
+ process.cwd(),
47
+ );
48
+ return { ...parsed.options, skipLibCheck: true };
45
49
  }
46
50
 
47
- const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = buildDefaultCompilerOptions();
51
+ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions =
52
+ buildDefaultCompilerOptions();
48
53
 
49
54
  /**
50
55
  * Walk up from startDir until we find a tsconfig.json, or hit the fs root.
51
56
  */
52
57
  function findTsConfig(startDir: string): string | null {
53
- let dir = startDir;
54
- while (true) {
55
- const candidate = path.join(dir, "tsconfig.json");
56
- if (fs.existsSync(candidate)) return candidate;
57
- const parent = path.dirname(dir);
58
- if (parent === dir) return null; // reached root
59
- dir = parent;
60
- }
58
+ let dir = startDir;
59
+ while (true) {
60
+ const candidate = path.join(dir, "tsconfig.json");
61
+ if (fs.existsSync(candidate)) return candidate;
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir) return null; // reached root
64
+ dir = parent;
65
+ }
61
66
  }
62
67
 
63
68
  /**
@@ -65,478 +70,498 @@ function findTsConfig(startDir: string): string | null {
65
70
  * Falls back to DEFAULT_COMPILER_OPTIONS on any error.
66
71
  */
67
72
  function loadCompilerOptions(tsconfigPath: string): ts.CompilerOptions {
68
- try {
69
- const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
70
- if (configFile.error) return DEFAULT_COMPILER_OPTIONS;
71
- const parsed = ts.parseJsonConfigFileContent(
72
- configFile.config,
73
- ts.sys,
74
- path.dirname(tsconfigPath),
75
- );
76
- if (parsed.errors.length) return DEFAULT_COMPILER_OPTIONS;
77
- // Always set skipLibCheck to avoid noise from node_modules
78
- return { ...parsed.options, skipLibCheck: true };
79
- } catch {
80
- return DEFAULT_COMPILER_OPTIONS;
81
- }
73
+ try {
74
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
75
+ if (configFile.error) return DEFAULT_COMPILER_OPTIONS;
76
+ const parsed = ts.parseJsonConfigFileContent(
77
+ configFile.config,
78
+ ts.sys,
79
+ path.dirname(tsconfigPath),
80
+ );
81
+ if (parsed.errors.length) return DEFAULT_COMPILER_OPTIONS;
82
+ // Always set skipLibCheck to avoid noise from node_modules
83
+ return { ...parsed.options, skipLibCheck: true };
84
+ } catch {
85
+ return DEFAULT_COMPILER_OPTIONS;
86
+ }
82
87
  }
83
88
 
84
89
  export class TypeScriptClient {
85
- private fileVersions = new Map<string, number>();
86
- private fileContents = new Map<string, string>();
87
- private languageService: ts.LanguageService | null = null;
88
- private compilerOptions: ts.CompilerOptions = DEFAULT_COMPILER_OPTIONS;
89
- private lastTsconfigDir: string | null = null;
90
-
91
- constructor() {
92
- this.initialize();
93
- }
94
-
95
- /**
96
- * Normalize file path for consistent cross-platform use
97
- */
98
- normalizePath(filePath: string): string {
99
- return path.resolve(filePath).replace(/\\/g, "/");
100
- }
101
-
102
- /**
103
- * Check if a file is a TypeScript/JavaScript file
104
- */
105
- isTypeScriptFile(filePath: string): boolean {
106
- const ext = path.extname(filePath).toLowerCase();
107
- return TS_EXTENSIONS.has(ext);
108
- }
109
-
110
- private initialize(): void {
111
- const host: ts.LanguageServiceHost = {
112
- getScriptFileNames: () => Array.from(this.fileContents.keys()),
113
- getScriptVersion: (fileName: string) => {
114
- const normalized = fileName.replace(/\\/g, "/");
115
- return String(this.fileVersions.get(normalized) ?? 0);
116
- },
117
- getScriptSnapshot: (fileName: string) => {
118
- const normalized = fileName.replace(/\\/g, "/");
119
- const content = this.fileContents.get(normalized);
120
- if (content) return ts.ScriptSnapshot.fromString(content);
121
- if (fs.existsSync(fileName)) {
122
- return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, "utf-8"));
123
- }
124
- return undefined;
125
- },
126
- getCurrentDirectory: () => process.cwd(),
127
- getCompilationSettings: () => this.compilerOptions,
128
- getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
129
- fileExists: (fileName) => ts.sys.fileExists(fileName),
130
- readFile: (fileName) => {
131
- const normalized = fileName.replace(/\\/g, "/");
132
- const cached = this.fileContents.get(normalized);
133
- if (cached !== undefined) return cached;
134
- return ts.sys.readFile(fileName);
135
- },
136
- directoryExists: (dirName) => ts.sys.directoryExists(dirName),
137
- getDirectories: (dir) => ts.sys.getDirectories(dir),
138
- };
139
-
140
- this.languageService = ts.createLanguageService(
141
- host,
142
- ts.createDocumentRegistry(),
143
- );
144
- }
145
-
146
- /**
147
- * Detect tsconfig for the given file and refresh compilerOptions if the
148
- * project root changed (avoids redundant re-parses across edits to the same project).
149
- */
150
- private refreshCompilerOptions(filePath: string): void {
151
- const dir = path.dirname(path.resolve(filePath));
152
- const tsconfigPath = findTsConfig(dir);
153
- const key = tsconfigPath ?? dir;
154
- if (key === this.lastTsconfigDir) return; // same project, no change
155
- this.lastTsconfigDir = key;
156
- this.compilerOptions = tsconfigPath
157
- ? loadCompilerOptions(tsconfigPath)
158
- : DEFAULT_COMPILER_OPTIONS;
159
- }
160
-
161
- /**
162
- * Add a file to the language service
163
- */
164
- addFile(filePath: string, content: string): void {
165
- const normalized = this.normalizePath(filePath);
166
- this.fileContents.set(normalized, content);
167
- this.fileVersions.set(
168
- normalized,
169
- (this.fileVersions.get(normalized) || 0) + 1,
170
- );
171
- }
172
-
173
- /**
174
- * Update a file's content — also refreshes compilerOptions if project changed
175
- */
176
- updateFile(filePath: string, content: string): void {
177
- this.refreshCompilerOptions(filePath);
178
- const normalized = this.normalizePath(filePath);
179
- this.fileVersions.set(normalized, (this.fileVersions.get(normalized) ?? 0) + 1);
180
- this.fileContents.set(normalized, content);
181
- }
182
-
183
- /**
184
- * Ensure a file is loaded from disk (refreshes cache)
185
- */
186
- ensureFile(filePath: string): void {
187
- const normalized = this.normalizePath(filePath);
188
- if (fs.existsSync(filePath)) {
189
- const diskContent = fs.readFileSync(filePath, "utf-8");
190
- const cachedContent = this.fileContents.get(normalized);
191
- if (cachedContent !== diskContent) {
192
- this.updateFile(filePath, diskContent);
193
- }
194
- }
195
- }
196
-
197
- /**
198
- * Get all tracked files
199
- */
200
- getTrackedFiles(): string[] {
201
- return Array.from(this.fileContents.keys());
202
- }
203
-
204
- /**
205
- * Convert line/character to position offset
206
- */
207
- lineCharToPosition(content: string, line: number, character: number): number {
208
- const lines = content.split("\n");
209
- let position = 0;
210
- for (let i = 0; i < Math.min(line, lines.length); i++) {
211
- position += lines[i].length + 1;
212
- }
213
- return position + character;
214
- }
215
-
216
- /**
217
- * Get diagnostics (errors and warnings) for a file
218
- */
219
- getDiagnostics(filePath: string): Diagnostic[] {
220
- this.refreshCompilerOptions(filePath);
221
- const normalized = this.normalizePath(filePath);
222
- this.ensureFile(filePath);
223
- if (!this.languageService) return [];
224
-
225
- const syntactic = this.languageService.getSyntacticDiagnostics(normalized);
226
- const semantic = this.languageService.getSemanticDiagnostics(normalized);
227
-
228
- return [...syntactic, ...semantic]
229
- .filter((diag) => diag.file && diag.start !== undefined)
230
- // Filter cross-file "redeclare" noise — happens when non-module scripts
231
- // share global scope across multiple tracked files (TS2300, TS2451)
232
- .filter((diag) => {
233
- if (diag.code !== 2300 && diag.code !== 2451) return true;
234
- // Only keep if the related information points back to the same file
235
- const related = diag.relatedInformation ?? [];
236
- return related.every(r => !r.file || this.normalizePath(r.file.fileName) === normalized);
237
- })
238
- .map((diag) => {
239
- const startPos = diag.file!.getLineAndCharacterOfPosition(diag.start!);
240
- const endPos = diag.file!.getLineAndCharacterOfPosition(
241
- diag.start! + diag.length!,
242
- );
243
- return {
244
- range: {
245
- start: { line: startPos.line, character: startPos.character },
246
- end: { line: endPos.line, character: endPos.character },
247
- },
248
- severity: (diag.category === ts.DiagnosticCategory.Error
249
- ? 1
250
- : 2) as DiagnosticSeverity,
251
- code: diag.code,
252
- message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"),
253
- source: "typescript",
254
- };
255
- });
256
- }
257
-
258
- /**
259
- * Get hover information at a position
260
- */
261
- getHover(filePath: string, line: number, character: number): HoverInfo | null {
262
- const normalized = this.normalizePath(filePath);
263
- this.ensureFile(filePath);
264
- if (!this.languageService) return null;
265
-
266
- const content = this.fileContents.get(normalized);
267
- if (!content) return null;
268
-
269
- const position = this.lineCharToPosition(content, line, character);
270
- const info = this.languageService.getQuickInfoAtPosition(
271
- normalized,
272
- position,
273
- );
274
- if (!info) return null;
275
-
276
- return {
277
- type: ts.displayPartsToString(info.displayParts),
278
- documentation: info.documentation
279
- ? ts.displayPartsToString(info.documentation)
280
- : undefined,
281
- };
282
- }
283
-
284
- /**
285
- * Go to definition
286
- */
287
- getDefinition(filePath: string, line: number, character: number): Location[] {
288
- const normalized = this.normalizePath(filePath);
289
- this.ensureFile(filePath);
290
- if (!this.languageService) return [];
291
-
292
- const content = this.fileContents.get(normalized);
293
- if (!content) return [];
294
-
295
- const position = this.lineCharToPosition(content, line, character);
296
- const definitions = this.languageService.getDefinitionAtPosition(
297
- normalized,
298
- position,
299
- );
300
- if (!definitions) return [];
301
-
302
- return definitions.map((def) => {
303
- const pos = def.fileName
304
- ? { line: 0, character: 0 }
305
- : { line: 0, character: 0 };
306
- // For file-based definitions, we need to get line/char from the span
307
- if (def.textSpan) {
308
- const defFile = def.fileName || normalized;
309
- const defContent = this.fileContents.get(defFile) || "";
310
- if (defContent) {
311
- const lines = defContent.substring(0, def.textSpan.start).split("\n");
312
- return {
313
- file: defFile,
314
- line: lines.length - 1,
315
- character: lines[lines.length - 1].length,
316
- };
317
- }
318
- }
319
- return { file: def.fileName, line: 0, character: 0 };
320
- });
321
- }
322
-
323
- /**
324
- * Get type definition
325
- */
326
- getTypeDefinition(
327
- filePath: string,
328
- line: number,
329
- character: number,
330
- ): Location[] {
331
- const normalized = this.normalizePath(filePath);
332
- this.ensureFile(filePath);
333
- if (!this.languageService) return [];
334
-
335
- const content = this.fileContents.get(normalized);
336
- if (!content) return [];
337
-
338
- const position = this.lineCharToPosition(content, line, character);
339
- const defs = this.languageService.getTypeDefinitionAtPosition(
340
- normalized,
341
- position,
342
- );
343
- if (!defs) return [];
344
-
345
- return defs.map((def) => {
346
- const defFile = def.fileName || normalized;
347
- return { file: defFile, line: 0, character: 0 };
348
- });
349
- }
350
-
351
- /**
352
- * Find references
353
- */
354
- getReferences(filePath: string, line: number, character: number): Location[] {
355
- const normalized = this.normalizePath(filePath);
356
- this.ensureFile(filePath);
357
- if (!this.languageService) return [];
358
-
359
- const content = this.fileContents.get(normalized);
360
- if (!content) return [];
361
-
362
- const position = this.lineCharToPosition(content, line, character);
363
- const references = this.languageService.getReferencesAtPosition(
364
- normalized,
365
- position,
366
- );
367
- if (!references) return [];
368
-
369
- return references.map((ref) => ({ file: ref.fileName, line: 0, character: 0 }));
370
- }
371
-
372
- /**
373
- * Get document symbols
374
- */
375
- getSymbols(filePath: string): SymbolInfo[] {
376
- const normalized = this.normalizePath(filePath);
377
- this.ensureFile(filePath);
378
- if (!this.languageService) return [];
379
-
380
- const tree = this.languageService.getNavigationTree(normalized);
381
- if (!tree) return [];
382
-
383
- const symbols: SymbolInfo[] = [];
384
-
385
- const extract = (node: any, container?: string) => {
386
- if (node.span) {
387
- symbols.push({
388
- name: node.text,
389
- kind: this.symbolKind(node.kind),
390
- line: 0,
391
- containerName: container,
392
- });
393
- }
394
- if (node.childItems) {
395
- for (const child of node.childItems) {
396
- extract(child, node.text);
397
- }
398
- }
399
- };
400
-
401
- extract(tree);
402
- return symbols;
403
- }
404
-
405
- /**
406
- * Get completions at a position
407
- */
408
- getCompletions(
409
- filePath: string,
410
- line: number,
411
- character: number,
412
- ): CompletionItem[] {
413
- const normalized = this.normalizePath(filePath);
414
- this.ensureFile(filePath);
415
- if (!this.languageService) return [];
416
-
417
- const content = this.fileContents.get(normalized);
418
- if (!content) return [];
419
-
420
- const position = this.lineCharToPosition(content, line, character);
421
- const completions = this.languageService.getCompletionsAtPosition(
422
- normalized,
423
- position,
424
- {},
425
- );
426
- if (!completions) return [];
427
-
428
- return completions.entries.slice(0, 50).map((entry) => ({
429
- name: entry.name,
430
- kind: this.completionKind(entry.kind),
431
- sortText: entry.sortText,
432
- }));
433
- }
434
-
435
- /**
436
- * Go to implementation
437
- */
438
- getImplementation(
439
- filePath: string,
440
- line: number,
441
- character: number,
442
- ): Location[] {
443
- const normalized = this.normalizePath(filePath);
444
- this.ensureFile(filePath);
445
- if (!this.languageService) return [];
446
-
447
- const content = this.fileContents.get(normalized);
448
- if (!content) return [];
449
-
450
- const position = this.lineCharToPosition(content, line, character);
451
- const implementations = this.languageService.getImplementationAtPosition(
452
- normalized,
453
- position,
454
- );
455
- if (!implementations) return [];
456
-
457
- return implementations.map((impl) => ({
458
- file: impl.fileName,
459
- line: 0,
460
- character: 0,
461
- }));
462
- }
463
-
464
- /**
465
- * Get folding ranges
466
- */
467
- getFoldingRanges(filePath: string): FoldingRange[] {
468
- const normalized = this.normalizePath(filePath);
469
- this.ensureFile(filePath);
470
- if (!this.languageService) return [];
471
-
472
- const tree = this.languageService.getNavigationTree(normalized);
473
- if (!tree) return [];
474
-
475
- const ranges: FoldingRange[] = [];
476
-
477
- const findFolds = (node: any) => {
478
- if (!node || !node.span) return;
479
-
480
- if (node.kind === "function" || node.kind === "class") {
481
- ranges.push({
482
- startLine: 0,
483
- endLine: 0,
484
- kind: node.kind,
485
- });
486
- }
487
-
488
- if (node.childItems) {
489
- for (const child of node.childItems) {
490
- findFolds(child);
491
- }
492
- }
493
- };
494
-
495
- findFolds(tree);
496
- return ranges;
497
- }
498
-
499
- /**
500
- * Explain an error at a specific line
501
- */
502
- explainError(
503
- filePath: string,
504
- line: number,
505
- ): { message: string; code?: number } | null {
506
- const diagnostics = this.getDiagnostics(filePath);
507
- const errorAtLine = diagnostics.find(
508
- (d) => d.range.start.line === line && d.severity === 1,
509
- );
510
- if (!errorAtLine) return null;
511
- return { message: errorAtLine.message, code: errorAtLine.code as number };
512
- }
513
-
514
- private symbolKind(kind: string): string {
515
- const map: Record<string, string> = {
516
- script: "file",
517
- class: "class",
518
- interface: "interface",
519
- function: "function",
520
- method: "method",
521
- property: "property",
522
- variable: "variable",
523
- enum: "enum",
524
- module: "module",
525
- };
526
- return map[kind] || "unknown";
527
- }
528
-
529
- private completionKind(kind: string): string {
530
- const map: Record<string, string> = {
531
- property: "property",
532
- method: "method",
533
- class: "class",
534
- interface: "interface",
535
- enum: "enum",
536
- variable: "variable",
537
- function: "function",
538
- keyword: "keyword",
539
- };
540
- return map[kind] || "text";
541
- }
90
+ private fileVersions = new Map<string, number>();
91
+ private fileContents = new Map<string, string>();
92
+ private languageService: ts.LanguageService | null = null;
93
+ private compilerOptions: ts.CompilerOptions = DEFAULT_COMPILER_OPTIONS;
94
+ private lastTsconfigDir: string | null = null;
95
+
96
+ constructor() {
97
+ this.initialize();
98
+ }
99
+
100
+ /**
101
+ * Normalize file path for consistent cross-platform use
102
+ */
103
+ normalizePath(filePath: string): string {
104
+ return path.resolve(filePath).replace(/\\/g, "/");
105
+ }
106
+
107
+ /**
108
+ * Check if a file is a TypeScript/JavaScript file
109
+ */
110
+ isTypeScriptFile(filePath: string): boolean {
111
+ const ext = path.extname(filePath).toLowerCase();
112
+ return TS_EXTENSIONS.has(ext);
113
+ }
114
+
115
+ private initialize(): void {
116
+ const host: ts.LanguageServiceHost = {
117
+ getScriptFileNames: () => Array.from(this.fileContents.keys()),
118
+ getScriptVersion: (fileName: string) => {
119
+ const normalized = fileName.replace(/\\/g, "/");
120
+ return String(this.fileVersions.get(normalized) ?? 0);
121
+ },
122
+ getScriptSnapshot: (fileName: string) => {
123
+ const normalized = fileName.replace(/\\/g, "/");
124
+ const content = this.fileContents.get(normalized);
125
+ if (content) return ts.ScriptSnapshot.fromString(content);
126
+ if (fs.existsSync(fileName)) {
127
+ return ts.ScriptSnapshot.fromString(
128
+ fs.readFileSync(fileName, "utf-8"),
129
+ );
130
+ }
131
+ return undefined;
132
+ },
133
+ getCurrentDirectory: () => process.cwd(),
134
+ getCompilationSettings: () => this.compilerOptions,
135
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
136
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
137
+ readFile: (fileName) => {
138
+ const normalized = fileName.replace(/\\/g, "/");
139
+ const cached = this.fileContents.get(normalized);
140
+ if (cached !== undefined) return cached;
141
+ return ts.sys.readFile(fileName);
142
+ },
143
+ directoryExists: (dirName) => ts.sys.directoryExists(dirName),
144
+ getDirectories: (dir) => ts.sys.getDirectories(dir),
145
+ };
146
+
147
+ this.languageService = ts.createLanguageService(
148
+ host,
149
+ ts.createDocumentRegistry(),
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Detect tsconfig for the given file and refresh compilerOptions if the
155
+ * project root changed (avoids redundant re-parses across edits to the same project).
156
+ */
157
+ private refreshCompilerOptions(filePath: string): void {
158
+ const dir = path.dirname(path.resolve(filePath));
159
+ const tsconfigPath = findTsConfig(dir);
160
+ const key = tsconfigPath ?? dir;
161
+ if (key === this.lastTsconfigDir) return; // same project, no change
162
+ this.lastTsconfigDir = key;
163
+ this.compilerOptions = tsconfigPath
164
+ ? loadCompilerOptions(tsconfigPath)
165
+ : DEFAULT_COMPILER_OPTIONS;
166
+ }
167
+
168
+ /**
169
+ * Add a file to the language service
170
+ */
171
+ addFile(filePath: string, content: string): void {
172
+ const normalized = this.normalizePath(filePath);
173
+ this.fileContents.set(normalized, content);
174
+ this.fileVersions.set(
175
+ normalized,
176
+ (this.fileVersions.get(normalized) || 0) + 1,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Update a file's content also refreshes compilerOptions if project changed
182
+ */
183
+ updateFile(filePath: string, content: string): void {
184
+ this.refreshCompilerOptions(filePath);
185
+ const normalized = this.normalizePath(filePath);
186
+ this.fileVersions.set(
187
+ normalized,
188
+ (this.fileVersions.get(normalized) ?? 0) + 1,
189
+ );
190
+ this.fileContents.set(normalized, content);
191
+ }
192
+
193
+ /**
194
+ * Ensure a file is loaded from disk (refreshes cache)
195
+ */
196
+ ensureFile(filePath: string): void {
197
+ const normalized = this.normalizePath(filePath);
198
+ if (fs.existsSync(filePath)) {
199
+ const diskContent = fs.readFileSync(filePath, "utf-8");
200
+ const cachedContent = this.fileContents.get(normalized);
201
+ if (cachedContent !== diskContent) {
202
+ this.updateFile(filePath, diskContent);
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Get all tracked files
209
+ */
210
+ getTrackedFiles(): string[] {
211
+ return Array.from(this.fileContents.keys());
212
+ }
213
+
214
+ /**
215
+ * Convert line/character to position offset
216
+ */
217
+ lineCharToPosition(content: string, line: number, character: number): number {
218
+ const lines = content.split("\n");
219
+ let position = 0;
220
+ for (let i = 0; i < Math.min(line, lines.length); i++) {
221
+ position += lines[i].length + 1;
222
+ }
223
+ return position + character;
224
+ }
225
+
226
+ /**
227
+ * Get diagnostics (errors and warnings) for a file
228
+ */
229
+ getDiagnostics(filePath: string): Diagnostic[] {
230
+ this.refreshCompilerOptions(filePath);
231
+ const normalized = this.normalizePath(filePath);
232
+ this.ensureFile(filePath);
233
+ if (!this.languageService) return [];
234
+
235
+ const syntactic = this.languageService.getSyntacticDiagnostics(normalized);
236
+ const semantic = this.languageService.getSemanticDiagnostics(normalized);
237
+
238
+ return (
239
+ [...syntactic, ...semantic]
240
+ .filter((diag) => diag.file && diag.start !== undefined)
241
+ // Filter cross-file "redeclare" noise happens when non-module scripts
242
+ // share global scope across multiple tracked files (TS2300, TS2451)
243
+ .filter((diag) => {
244
+ if (diag.code !== 2300 && diag.code !== 2451) return true;
245
+ // Only keep if the related information points back to the same file
246
+ const related = diag.relatedInformation ?? [];
247
+ return related.every(
248
+ (r) =>
249
+ !r.file || this.normalizePath(r.file.fileName) === normalized,
250
+ );
251
+ })
252
+ .map((diag) => {
253
+ const startPos = diag.file?.getLineAndCharacterOfPosition(
254
+ diag.start!,
255
+ )!;
256
+ const endPos = diag.file?.getLineAndCharacterOfPosition(
257
+ diag.start! + diag.length!,
258
+ )!;
259
+ return {
260
+ range: {
261
+ start: { line: startPos.line, character: startPos.character },
262
+ end: { line: endPos.line, character: endPos.character },
263
+ },
264
+ severity: (diag.category === ts.DiagnosticCategory.Error
265
+ ? 1
266
+ : 2) as DiagnosticSeverity,
267
+ code: diag.code,
268
+ message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"),
269
+ source: "typescript",
270
+ };
271
+ })
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Get hover information at a position
277
+ */
278
+ getHover(
279
+ filePath: string,
280
+ line: number,
281
+ character: number,
282
+ ): HoverInfo | null {
283
+ const normalized = this.normalizePath(filePath);
284
+ this.ensureFile(filePath);
285
+ if (!this.languageService) return null;
286
+
287
+ const content = this.fileContents.get(normalized);
288
+ if (!content) return null;
289
+
290
+ const position = this.lineCharToPosition(content, line, character);
291
+ const info = this.languageService.getQuickInfoAtPosition(
292
+ normalized,
293
+ position,
294
+ );
295
+ if (!info) return null;
296
+
297
+ return {
298
+ type: ts.displayPartsToString(info.displayParts),
299
+ documentation: info.documentation
300
+ ? ts.displayPartsToString(info.documentation)
301
+ : undefined,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Go to definition
307
+ */
308
+ getDefinition(filePath: string, line: number, character: number): Location[] {
309
+ const normalized = this.normalizePath(filePath);
310
+ this.ensureFile(filePath);
311
+ if (!this.languageService) return [];
312
+
313
+ const content = this.fileContents.get(normalized);
314
+ if (!content) return [];
315
+
316
+ const position = this.lineCharToPosition(content, line, character);
317
+ const definitions = this.languageService.getDefinitionAtPosition(
318
+ normalized,
319
+ position,
320
+ );
321
+ if (!definitions) return [];
322
+
323
+ return definitions.map((def) => {
324
+ const _pos = def.fileName
325
+ ? { line: 0, character: 0 }
326
+ : { line: 0, character: 0 };
327
+ // For file-based definitions, we need to get line/char from the span
328
+ if (def.textSpan) {
329
+ const defFile = def.fileName || normalized;
330
+ const defContent = this.fileContents.get(defFile) || "";
331
+ if (defContent) {
332
+ const lines = defContent.substring(0, def.textSpan.start).split("\n");
333
+ return {
334
+ file: defFile,
335
+ line: lines.length - 1,
336
+ character: lines[lines.length - 1].length,
337
+ };
338
+ }
339
+ }
340
+ return { file: def.fileName, line: 0, character: 0 };
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Get type definition
346
+ */
347
+ getTypeDefinition(
348
+ filePath: string,
349
+ line: number,
350
+ character: number,
351
+ ): Location[] {
352
+ const normalized = this.normalizePath(filePath);
353
+ this.ensureFile(filePath);
354
+ if (!this.languageService) return [];
355
+
356
+ const content = this.fileContents.get(normalized);
357
+ if (!content) return [];
358
+
359
+ const position = this.lineCharToPosition(content, line, character);
360
+ const defs = this.languageService.getTypeDefinitionAtPosition(
361
+ normalized,
362
+ position,
363
+ );
364
+ if (!defs) return [];
365
+
366
+ return defs.map((def) => {
367
+ const defFile = def.fileName || normalized;
368
+ return { file: defFile, line: 0, character: 0 };
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Find references
374
+ */
375
+ getReferences(filePath: string, line: number, character: number): Location[] {
376
+ const normalized = this.normalizePath(filePath);
377
+ this.ensureFile(filePath);
378
+ if (!this.languageService) return [];
379
+
380
+ const content = this.fileContents.get(normalized);
381
+ if (!content) return [];
382
+
383
+ const position = this.lineCharToPosition(content, line, character);
384
+ const references = this.languageService.getReferencesAtPosition(
385
+ normalized,
386
+ position,
387
+ );
388
+ if (!references) return [];
389
+
390
+ return references.map((ref) => ({
391
+ file: ref.fileName,
392
+ line: 0,
393
+ character: 0,
394
+ }));
395
+ }
396
+
397
+ /**
398
+ * Get document symbols
399
+ */
400
+ getSymbols(filePath: string): SymbolInfo[] {
401
+ const normalized = this.normalizePath(filePath);
402
+ this.ensureFile(filePath);
403
+ if (!this.languageService) return [];
404
+
405
+ const tree = this.languageService.getNavigationTree(normalized);
406
+ if (!tree) return [];
407
+
408
+ const symbols: SymbolInfo[] = [];
409
+
410
+ const extract = (node: any, container?: string) => {
411
+ if (node.span) {
412
+ symbols.push({
413
+ name: node.text,
414
+ kind: this.symbolKind(node.kind),
415
+ line: 0,
416
+ containerName: container,
417
+ });
418
+ }
419
+ if (node.childItems) {
420
+ for (const child of node.childItems) {
421
+ extract(child, node.text);
422
+ }
423
+ }
424
+ };
425
+
426
+ extract(tree);
427
+ return symbols;
428
+ }
429
+
430
+ /**
431
+ * Get completions at a position
432
+ */
433
+ getCompletions(
434
+ filePath: string,
435
+ line: number,
436
+ character: number,
437
+ ): CompletionItem[] {
438
+ const normalized = this.normalizePath(filePath);
439
+ this.ensureFile(filePath);
440
+ if (!this.languageService) return [];
441
+
442
+ const content = this.fileContents.get(normalized);
443
+ if (!content) return [];
444
+
445
+ const position = this.lineCharToPosition(content, line, character);
446
+ const completions = this.languageService.getCompletionsAtPosition(
447
+ normalized,
448
+ position,
449
+ {},
450
+ );
451
+ if (!completions) return [];
452
+
453
+ return completions.entries.slice(0, 50).map((entry) => ({
454
+ name: entry.name,
455
+ kind: this.completionKind(entry.kind),
456
+ sortText: entry.sortText,
457
+ }));
458
+ }
459
+
460
+ /**
461
+ * Go to implementation
462
+ */
463
+ getImplementation(
464
+ filePath: string,
465
+ line: number,
466
+ character: number,
467
+ ): Location[] {
468
+ const normalized = this.normalizePath(filePath);
469
+ this.ensureFile(filePath);
470
+ if (!this.languageService) return [];
471
+
472
+ const content = this.fileContents.get(normalized);
473
+ if (!content) return [];
474
+
475
+ const position = this.lineCharToPosition(content, line, character);
476
+ const implementations = this.languageService.getImplementationAtPosition(
477
+ normalized,
478
+ position,
479
+ );
480
+ if (!implementations) return [];
481
+
482
+ return implementations.map((impl) => ({
483
+ file: impl.fileName,
484
+ line: 0,
485
+ character: 0,
486
+ }));
487
+ }
488
+
489
+ /**
490
+ * Get folding ranges
491
+ */
492
+ getFoldingRanges(filePath: string): FoldingRange[] {
493
+ const normalized = this.normalizePath(filePath);
494
+ this.ensureFile(filePath);
495
+ if (!this.languageService) return [];
496
+
497
+ const tree = this.languageService.getNavigationTree(normalized);
498
+ if (!tree) return [];
499
+
500
+ const ranges: FoldingRange[] = [];
501
+
502
+ const findFolds = (node: any) => {
503
+ if (!node?.span) return;
504
+
505
+ if (node.kind === "function" || node.kind === "class") {
506
+ ranges.push({
507
+ startLine: 0,
508
+ endLine: 0,
509
+ kind: node.kind,
510
+ });
511
+ }
512
+
513
+ if (node.childItems) {
514
+ for (const child of node.childItems) {
515
+ findFolds(child);
516
+ }
517
+ }
518
+ };
519
+
520
+ findFolds(tree);
521
+ return ranges;
522
+ }
523
+
524
+ /**
525
+ * Explain an error at a specific line
526
+ */
527
+ explainError(
528
+ filePath: string,
529
+ line: number,
530
+ ): { message: string; code?: number } | null {
531
+ const diagnostics = this.getDiagnostics(filePath);
532
+ const errorAtLine = diagnostics.find(
533
+ (d) => d.range.start.line === line && d.severity === 1,
534
+ );
535
+ if (!errorAtLine) return null;
536
+ return { message: errorAtLine.message, code: errorAtLine.code as number };
537
+ }
538
+
539
+ private symbolKind(kind: string): string {
540
+ const map: Record<string, string> = {
541
+ script: "file",
542
+ class: "class",
543
+ interface: "interface",
544
+ function: "function",
545
+ method: "method",
546
+ property: "property",
547
+ variable: "variable",
548
+ enum: "enum",
549
+ module: "module",
550
+ };
551
+ return map[kind] || "unknown";
552
+ }
553
+
554
+ private completionKind(kind: string): string {
555
+ const map: Record<string, string> = {
556
+ property: "property",
557
+ method: "method",
558
+ class: "class",
559
+ interface: "interface",
560
+ enum: "enum",
561
+ variable: "variable",
562
+ function: "function",
563
+ keyword: "keyword",
564
+ };
565
+ return map[kind] || "text";
566
+ }
542
567
  }